changedetection.io icon indicating copy to clipboard operation
changedetection.io copied to clipboard

Fix static redirect url

Open Genva opened this issue 1 month ago • 4 comments

Currently the app is always redirecting to the root path after login. This is kind of annoying and defeats the purpose of using the {{ diff_url }} placeholder in notification templates.

Expected behavior I click on the link, login and I am getting redirected to the generated preview page.

Actual behavior I click on the link, login and I am on the watch list page. In order to see the preview page I either have to open the link again or I manually navigate there.

This PR should fix the issue.

Genva avatar Nov 10 '25 18:11 Genva

there was some "safe flask redirect" setup to prevent various XSS hacks through this, do you know anything about it? thats mainly why it wasnt there

dgtlmoon avatar Nov 10 '25 19:11 dgtlmoon

I did a quick research on the "safe flask redirect" setup and added the is_safe_url() method. Like this only local paths of the host the application is running on are allowed. Redirect tampering to external urls such as https://example.com or external apps (myapp://some/path) won't load anymore. Instead the watch list opens.

Genva avatar Nov 11 '25 11:11 Genva

can you add a test in the test_security.py ? theres a login part, we could copy that to a new test in that file and then check the redirect lands on the right page (follow_redirect=true arg can be seen in other examples)

and also that redirect doesnt bounce us out to google or something

dgtlmoon avatar Nov 11 '25 14:11 dgtlmoon

Added three tests. Let me know if there's anything else :)

Genva avatar Nov 11 '25 19:11 Genva

Heres a machine generated security report of the current PR...

1. Open Redirect Vulnerability in is_safe_url() Implementation ⚠️

  The is_safe_url() function has a critical flaw:

  def is_safe_url(path):
      ref_url = urlparse(request.host_url)
      test_url = urlparse(urljoin(request.host_url, path))
      return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc

  Problem: This validation can be bypassed using protocol-relative URLs and absolute URLs with path confusion. Here are attack vectors:

  Attack Vector 1: Protocol-relative URL
  //evil.com
  When passed to urljoin(), this becomes a valid absolute URL to evil.com while passing the validation if not carefully checked.

  Attack Vector 2: URL with embedded newlines/CRLF
  /some/path%0D%0ALocation:%20https://evil.com

  Attack Vector 3: URL with @ symbol
  //@evil.com
  Depending on how urljoin processes this, it might interpret it as a netloc.

  2. Missing Input Sanitization

  The redirect parameter is taken directly from user input without length limits or character validation:
  redirect_arg = request.args.get('redirect') or request.form.get('redirect')

  This could allow:
  - Very long URLs that could cause memory issues
  - Special characters that might cause issues in downstream processing
  - URL encoding bypasses (double-encoding attacks)

  3. Unsafe Fallback Behavior

  When redirect_arg is None or fails validation, it falls back to:
  redirect_url = redirect_arg or url_for('watchlist.index')

  However, if redirect_arg is an empty string "", it's falsy in Python but might have already been validated. This could lead to unexpected behavior.

dgtlmoon avatar Nov 12 '25 11:11 dgtlmoon

  def is_safe_redirect_url(target):
      """Validate redirect URLs are internal relative paths only."""
      if not target or not isinstance(target, str):
          return False
      target = target.strip()
      # Only allow relative paths starting with /
      # Allow fragments (#) and query params (?)
      return target.startswith('/') and not target.startswith('//')

Examples that work:

  • /dashboard ✅
  • /settings#profile ✅ (fragment)
  • /page?foo=bar ✅ (query param)
  • /page?foo=bar#section ✅ (query + fragment)
  • /api/users#top ✅

Examples that are blocked:

  • //evil.com#fake ❌ (starts with //)
  • http://evil.com#fake ❌ (doesn't start with /)
  • #fragment ❌ (doesn't start with /)

The only edge case is if someone wants to redirect to just a fragment on the current page like #section, but that's not really a redirect - that's a client-side anchor navigation that should be handled differently (usually just href="#section" in HTML, not a server redirect).

dgtlmoon avatar Nov 12 '25 11:11 dgtlmoon

The current implementation works very similar. The difference is that sections as redirects are allowed. Passing a section as redirect has the same effect as /#section, what imo is not a problem. I added more tests for the other scenarios provided by the security report.

Genva avatar Nov 13 '25 14:11 Genva

it really needs these extra checks

      """Validate redirect URLs are internal relative paths only."""
      if not target or not isinstance(target, str):
          return False
      target = target.strip()
      # Only allow relative paths starting with /
      # Allow fragments (#) and query params (?)
      return target.startswith('/') and not target.startswith('//')

dgtlmoon avatar Nov 13 '25 14:11 dgtlmoon

@Genva when the redirect field is ////evil.com it takes me to evil.com, but that was before d7ac0cb, please add a test for the quadslash attack

dgtlmoon avatar Nov 13 '25 15:11 dgtlmoon

done

Genva avatar Nov 13 '25 16:11 Genva

in your test, it should prove that accessing /login?redirect=settings or whatever should work too, then all notifiactions can always include that /login as default but it will work even if theres no password or they are already logged in

it needs test that this works both with and without password setup

dgtlmoon avatar Nov 13 '25 18:11 dgtlmoon

whatever...

Genva avatar Nov 14 '25 17:11 Genva

??????

dgtlmoon avatar Nov 14 '25 19:11 dgtlmoon

these kinds of edge cases are exactly what i deal with every day, i cannot just accept a PR without this stuff covered, or i'm the one who has to deal with, its not fair on me, is it?

dgtlmoon avatar Nov 14 '25 19:11 dgtlmoon