VYPR
Moderate severityNVD Advisory· Published Dec 9, 2025· Updated Dec 10, 2025

Taguette does not safeguard against Open Redirect

CVE-2025-67502

Description

Taguette is an open source qualitative research tool. In versions 1.5.1 and below, attackers can craft malicious URLs that redirect users to arbitrary external websites after authentication. The application accepts a user-controlled next parameter and uses it directly in HTTP redirects without any validation. This can be exploited for phishing attacks where victims believe they are interacting with a trusted Taguette instance but are redirected to a malicious site designed to steal credentials or deliver malware. This issue is fixed in version 1.5.2.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
taguettePyPI
< 1.5.21.5.2

Affected products

1

Patches

1
67de2d2612e7

Validate 'next' URL from login and cookies prompt

https://github.com/remram44/taguetteRemi RampinDec 8, 2025via ghsa
3 files changed · +51 6
  • taguette/web/base.py+14 0 modified
    @@ -13,6 +13,7 @@
     import os
     from prometheus_async.aio import time as prom_async_time
     import prometheus_client
    +import re
     import redis.asyncio as aioredis
     import signal
     import smtplib
    @@ -342,6 +343,13 @@ def send_error(self, status_code=500, **kwargs):
             super(HandleStreamClosed, self).send_error(status_code, **kwargs)
     
     
    +def is_next_url_safe(url, base_path):
    +    return url and re.match(
    +        re.escape(base_path) + r'/[a-z]([a-z0-9/_-]|\.[^.])+(?:\?.*)?$',
    +        url,
    +    )
    +
    +
     class BaseHandler(HandleStreamClosed, RequestHandler):
         """Base class for all request handlers.
         """
    @@ -462,6 +470,12 @@ def logout(self):
             logger.info("Logged out")
             self.clear_cookie('user')
     
    +    def sanitize_next_url(self, url):
    +        if is_next_url_safe(url, self.application.config['BASE_PATH']):
    +            return url
    +        else:
    +            return self.reverse_url('index')
    +
         def render_string(self, template_name, **kwargs):
             extra_footer = self.application.config['EXTRA_FOOTER']
             if not extra_footer:
    
  • taguette/web/views.py+2 6 modified
    @@ -79,9 +79,7 @@ def get(self):
         @PROM_REQUESTS.sync('cookies_prompt')
         def post(self):
             self.set_cookie('cookies_accepted', 'yes', dont_check=True)
    -        next_ = self.get_argument('next', '')
    -        if not next_:
    -            next_ = self.reverse_url('index')
    +        next_ = self.sanitize_next_url(self.get_argument('next', ''))
             return self.redirect(next_)
     
         def check_xsrf_cookie(self):
    @@ -138,9 +136,7 @@ async def post(self):
             )
     
         def _go_to_next(self):
    -        next_ = self.get_argument('next', '')
    -        if not next_:
    -            next_ = self.reverse_url('index')
    +        next_ = self.sanitize_next_url(self.get_argument('next', ''))
             return self.redirect(next_)
     
     
    
  • tests.py+35 0 modified
    @@ -26,6 +26,7 @@
     from taguette import exact_version
     from taguette import convert, database, extract, import_codebook, main, \
         validate, web
    +from taguette.web.base import is_next_url_safe
     from taguette.utils import sanitize_filename
     
     
    @@ -379,6 +380,20 @@ def test_export_filename(self):
                 "Rmis project",
             )
     
    +    def test_next_url(self):
    +        self.assertFalse(is_next_url_safe('https://google.com/', ''))
    +        self.assertFalse(is_next_url_safe('https://goo.gl/', '/goo.gl'))
    +        self.assertTrue(is_next_url_safe('/project/1', ''))
    +        self.assertTrue(is_next_url_safe('/project?a=1&b=2', ''))
    +        self.assertTrue(is_next_url_safe('/project/export.csv', ''))
    +        self.assertFalse(is_next_url_safe('/a/../b', ''))
    +        self.assertFalse(is_next_url_safe('/a', '/base'))
    +        self.assertFalse(is_next_url_safe('/base/../a', '/base'))
    +        self.assertTrue(is_next_url_safe('/base/project/1', '/base'))
    +        self.assertTrue(is_next_url_safe('/b.se/project/1', '/b.se'))
    +        self.assertFalse(is_next_url_safe('/bose/project/1', '/b.se'))
    +        self.assertTrue(is_next_url_safe('/base/project/export.csv', '/base'))
    +
     
     class TestReadCodebook(unittest.TestCase):
         def test_valid_csv(self):
    @@ -661,6 +676,26 @@ async def test_login(self):
                 self.assertEqual(response.status, 303)
                 self.assertEqual(response.headers['Location'], '/project/1')
     
    +        # Login page now instantly redirects
    +        async with self.aget(
    +            '/login?' + urlencode(dict(next='/project/1')),
    +        ) as response:
    +            self.assertEqual(response.status, 302)
    +            self.assertEqual(response.headers['Location'], '/project/1')
    +
    +        # Watch out for CVE-2025-67502
    +        async def test_redirects_to_index(url):
    +            async with self.aget(
    +                '/login?' + urlencode(dict(next=url)),
    +            ) as response:
    +                self.assertEqual(response.status, 302)
    +                self.assertEqual(response.headers['Location'], '/')
    +
    +        await test_redirects_to_index('https://google.com/')
    +        await test_redirects_to_index('//google.com/')
    +        await test_redirects_to_index('../something')
    +        await test_redirects_to_index('/')
    +
             # Check redirect to account
             async with self.aget('/.well-known/change-password') as response:
                 self.assertEqual(response.status, 301)
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.