mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-08-26 16:17:13 +00:00
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-10 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
454 lines
20 KiB
Python
454 lines
20 KiB
Python
import ipaddress
|
|
import socket
|
|
from functools import lru_cache
|
|
from loguru import logger
|
|
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode, quote, unquote
|
|
|
|
|
|
def normalize_url_encoding(url):
|
|
"""
|
|
Safely encode a URL's query parameters, regardless of whether they're already encoded.
|
|
|
|
Why this is necessary:
|
|
URLs can arrive in various states - some with already encoded query parameters (%20 for spaces),
|
|
some with unencoded parameters (literal spaces), or a mix of both. The validators.url() function
|
|
requires proper encoding, but simply encoding an already-encoded URL would double-encode it
|
|
(e.g., %20 would become %2520).
|
|
|
|
This function solves the problem by:
|
|
1. Parsing the URL to extract query parameters
|
|
2. parse_qsl() automatically decodes parameters if they're encoded
|
|
3. urlencode() re-encodes them properly
|
|
4. Returns a consistently encoded URL that will pass validation
|
|
|
|
Example:
|
|
- Input: "http://example.com/test?time=2025-10-28 09:19" (space not encoded)
|
|
- Output: "http://example.com/test?time=2025-10-28+09%3A19" (properly encoded)
|
|
|
|
- Input: "http://example.com/test?time=2025-10-28%2009:19" (already encoded)
|
|
- Output: "http://example.com/test?time=2025-10-28+09%3A19" (properly encoded)
|
|
|
|
Returns a properly encoded URL string.
|
|
"""
|
|
try:
|
|
# Parse the URL into components (scheme, netloc, path, params, query, fragment)
|
|
parsed = urlparse(url)
|
|
|
|
# Parse query string - this automatically decodes it if encoded
|
|
# parse_qsl handles both encoded and unencoded query strings gracefully
|
|
query_params = parse_qsl(parsed.query, keep_blank_values=True)
|
|
|
|
# Re-encode the query string properly using standard URL encoding
|
|
encoded_query = urlencode(query_params, safe='')
|
|
|
|
# Fragments need the same treatment - browsers accept characters there that
|
|
# RFC 3986 does not (e.g. '|' in "...w-4#int=S:PFreco|PF|48966", see issue #4209)
|
|
# and validators.url() would reject them. unquote() first so an already-encoded
|
|
# fragment isn't double-encoded, then re-quote keeping every character that
|
|
# RFC 3986 allows in a fragment (pchar / "/" / "?") intact.
|
|
encoded_fragment = quote(unquote(parsed.fragment), safe="/?:@!$&'()*+,;=")
|
|
|
|
# Reconstruct the URL with properly encoded query string
|
|
normalized = urlunparse((
|
|
parsed.scheme,
|
|
parsed.netloc,
|
|
parsed.path,
|
|
parsed.params,
|
|
encoded_query, # Use the re-encoded query
|
|
encoded_fragment # Use the re-encoded fragment
|
|
))
|
|
|
|
return normalized
|
|
except Exception as e:
|
|
# If parsing fails for any reason, return original URL
|
|
logger.debug(f"URL normalization failed for '{url}': {e}")
|
|
return url
|
|
|
|
|
|
# Address blocks that are NOT globally reachable but which Python's ipaddress flags
|
|
# (is_private / is_loopback / is_link_local / is_reserved) do not report — so relying on
|
|
# those four alone leaves them fetchable. Each entry states what it is and why it matters:
|
|
#
|
|
# 100.64.0.0/10 RFC 6598 Carrier-Grade NAT "shared address space". Not private per
|
|
# ipaddress, yet it is exactly where an ISP/cloud tenant's other
|
|
# customers, CPE admin panels and CGNAT gateways live. This is the
|
|
# incomplete-remediation gap reported in GHSA-gwph-fp79-379w.
|
|
# 192.88.99.0/24 RFC 7526 deprecated 6to4 relay anycast.
|
|
# 224.0.0.0/4 IPv4 multicast (includes 224.0.0.1 all-hosts).
|
|
# ff00::/8 IPv6 multicast (includes ff02::1 link-local all-nodes).
|
|
# 3fff::/20 RFC 9637 documentation range; unknown to older Python releases.
|
|
#
|
|
# Kept as an explicit, documented list rather than leaning only on `not ip.is_global`
|
|
# because the meaning of is_global has shifted between Python releases; the is_global
|
|
# check below is a belt-and-braces catch-all on top of these, never a replacement.
|
|
_NON_GLOBAL_EXTRA_NETWORKS = tuple(
|
|
ipaddress.ip_network(n) for n in (
|
|
'100.64.0.0/10',
|
|
'192.88.99.0/24',
|
|
'224.0.0.0/4',
|
|
'ff00::/8',
|
|
'3fff::/20',
|
|
)
|
|
)
|
|
|
|
|
|
def is_special_purpose_ip(ip):
|
|
"""Return (blocked: bool, why: str) for a single resolved IP address.
|
|
|
|
THE one place that decides "is this address off-limits to the fetcher?". Everything
|
|
that resolves a hostname must funnel through here so a newly-discovered range is
|
|
added once, not once per call site.
|
|
|
|
IPv6 addresses that embed an IPv4 address (IPv4-mapped, 6to4, Teredo) are unwrapped
|
|
and re-checked, so e.g. ::ffff:100.64.0.1 cannot smuggle a blocked v4 address past a
|
|
v6-only classification.
|
|
"""
|
|
if isinstance(ip, str):
|
|
ip = ipaddress.ip_address(ip)
|
|
|
|
if ip.is_private:
|
|
return True, 'private'
|
|
if ip.is_loopback:
|
|
return True, 'loopback'
|
|
if ip.is_link_local:
|
|
return True, 'link-local'
|
|
if ip.is_reserved:
|
|
return True, 'reserved'
|
|
if ip.is_multicast:
|
|
return True, 'multicast'
|
|
if ip.is_unspecified:
|
|
return True, 'unspecified'
|
|
|
|
for net in _NON_GLOBAL_EXTRA_NETWORKS:
|
|
if ip.version == net.version and ip in net:
|
|
return True, f'in non-globally-reachable range {net}'
|
|
|
|
# Catch-all for ranges this Python release knows are not globally reachable but which
|
|
# none of the flags above expose (100.64.0.0/10 is such a case on CPython 3.12).
|
|
if not ip.is_global:
|
|
return True, 'not globally reachable'
|
|
|
|
# An IPv6 address carrying an IPv4 payload is only as safe as that payload.
|
|
for embedded in (getattr(ip, 'ipv4_mapped', None), getattr(ip, 'sixtofour', None), getattr(ip, 'teredo', None)):
|
|
if embedded is None:
|
|
continue
|
|
# .teredo returns a (server, client) tuple; the others return a single address.
|
|
for candidate in (embedded if isinstance(embedded, tuple) else (embedded,)):
|
|
blocked, why = is_special_purpose_ip(candidate)
|
|
if blocked:
|
|
return True, f'embeds IPv4 address {candidate} ({why})'
|
|
|
|
return False, ''
|
|
|
|
|
|
def is_private_hostname(hostname):
|
|
"""Return True if hostname resolves to an IANA-restricted (private/reserved/non-global) IP address.
|
|
|
|
Unresolvable hostnames return False (allow them) — DNS may be temporarily unavailable
|
|
or the domain not yet live. The actual DNS rebinding attack is mitigated by fetch-time
|
|
re-validation in requests.py, not by blocking unresolvable domains at add-time.
|
|
Never cached — callers that need fresh DNS resolution (e.g. at fetch time) can call
|
|
this directly without going through the lru_cached is_safe_valid_url().
|
|
|
|
A hostname is refused if ANY of its A/AAAA records is off-limits: a name that answers
|
|
with one public and one CGNAT address is still a route to the CGNAT address.
|
|
"""
|
|
try:
|
|
for info in socket.getaddrinfo(hostname, None):
|
|
ip = ipaddress.ip_address(info[4][0])
|
|
blocked, why = is_special_purpose_ip(ip)
|
|
if blocked:
|
|
logger.warning(f"Hostname '{hostname}' resolves to {ip} which is {why} — refused.")
|
|
return True
|
|
except socket.gaierror as e:
|
|
logger.warning(f"{hostname} error checking {str(e)}")
|
|
return False
|
|
except ValueError as e:
|
|
# getaddrinfo handed back something ip_address() won't parse - fail closed.
|
|
logger.warning(f"Hostname '{hostname}' produced an unparseable address ({e}) — refused.")
|
|
return True
|
|
logger.info(f"Hostname '{hostname}' is NOT private/IANA restricted.")
|
|
return False
|
|
|
|
|
|
def extract_url_hostnames(url):
|
|
"""Return every hostname this URL could resolve to under different URL parsers.
|
|
|
|
Why: urllib's urlparse() and urllib3's parse_url() disagree on URLs containing
|
|
a backslash (e.g. http://INTERNAL:8888\\@PUBLIC/ — urlparse extracts PUBLIC, but
|
|
urllib3/requests will actually connect to INTERNAL). Any SSRF check that trusts
|
|
only one parser can be bypassed by the other. Callers should reject the fetch
|
|
if ANY hostname returned here is private/reserved.
|
|
|
|
See GHSA-rph4-96w6-q594.
|
|
"""
|
|
hostnames = set()
|
|
try:
|
|
h = urlparse(url).hostname
|
|
if h:
|
|
hostnames.add(h)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from urllib3.util.url import parse_url as _u3_parse_url
|
|
u3 = _u3_parse_url(url)
|
|
if u3.host:
|
|
# urllib3 keeps IPv6 brackets in `.host`; strip them so socket.getaddrinfo() accepts the literal.
|
|
hostnames.add(u3.host.strip('[]'))
|
|
except Exception:
|
|
pass
|
|
return hostnames
|
|
|
|
|
|
def is_url_private_or_parser_confused(url):
|
|
"""SSRF gate that defends against urlparse/urllib3 parser-differential attacks.
|
|
|
|
Returns True (block the fetch) when:
|
|
* the URL contains a backslash — no legitimate URL needs one, and it is the
|
|
established vector for the parser-differential bypass (GHSA-rph4-96w6-q594), OR
|
|
* any hostname produced by urlparse OR urllib3 resolves to an address that
|
|
is_special_purpose_ip() refuses (private, loopback, link-local, reserved,
|
|
multicast, CGNAT/RFC 6598 or otherwise not globally reachable).
|
|
"""
|
|
if '\\' in url:
|
|
logger.warning(f"URL '{url}' contains a backslash — rejected to prevent urlparse/urllib3 parser-differential SSRF.")
|
|
return True
|
|
for hostname in extract_url_hostnames(url):
|
|
if is_private_hostname(hostname):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_fetch_url_allowed(url):
|
|
"""THE single gate for "is the server allowed to fetch this URL?".
|
|
|
|
Returns (ok: bool, reason: str) — `reason` is safe to show the user.
|
|
|
|
Call this from EVERY entry point that causes a server-side fetch. The checks used to live
|
|
inline in difference_detection_processor.call_browser(), on the documented assumption that
|
|
"every fetch goes through call_browser()". That stopped being true once the live Browser
|
|
Steps UI and the Add Watch snapshot preview grew their own fetch paths — each silently
|
|
skipped both the file:// and the private-IP gate (GHSA-hm22-wg2m-35v4, GHSA-56fq-63vj-9992).
|
|
Rather than re-assert that invariant, every fetch path now calls this function.
|
|
|
|
Layers, in order:
|
|
1. Render Jinja2 and strip the 'source:' meta prefix, so what gets checked is what the
|
|
browser/requests library will actually be handed. Stripping is load-bearing, not
|
|
cosmetic: urlparse('source:http://127.0.0.1/') reports NO hostname at all, so an
|
|
unstripped value sails straight past the private-IP check in step 5.
|
|
2. file:// refused unless ALLOW_FILE_URI=true. Checked explicitly rather than leaning on
|
|
is_safe_valid_url()'s scheme allowlist, because an operator who loosened
|
|
SAFE_PROTOCOL_REGEX for some other scheme should not silently get local file reads too.
|
|
3. Backslash rejection (GHSA-rph4-96w6-q594) — unconditional, including when the operator
|
|
has opted into private addresses.
|
|
4. is_safe_valid_url() — scheme allowlist, '<>' rejection, validators.url().
|
|
5. Non-globally-reachable address rejection, unless ALLOW_IANA_RESTRICTED_ADDRESSES=true.
|
|
This step owns no logic of its own: it delegates to is_private_hostname() ->
|
|
is_special_purpose_ip(), which is the single list of refused address classes
|
|
(private, loopback, link-local, reserved, multicast, RFC 6598 CGNAT, ...). Add new
|
|
ranges there and every fetch path, the notification handlers and the LLM api_base
|
|
check pick them up together — see GHSA-gwph-fp79-379w, where CGNAT space was missing
|
|
from that predicate and so this gate let 100.64.0.0/10 through.
|
|
|
|
Step 5 performs DNS resolution and therefore blocks. From async code call
|
|
validate_fetch_url_async() instead so the event loop keeps turning.
|
|
|
|
Note this validates one URL, not a redirect chain. content_fetchers/requests.py follows
|
|
redirects manually and re-checks each hop; the Chromium-based fetchers cannot do that yet,
|
|
so an open redirect on a public host remains a known gap for those backends.
|
|
"""
|
|
import os
|
|
import re
|
|
from changedetectionio.strtobool import strtobool
|
|
from changedetectionio.jinja2_custom import render as jinja_render
|
|
|
|
if not url or not isinstance(url, str) or not url.strip():
|
|
return False, "No URL specified."
|
|
|
|
url = url.strip()
|
|
|
|
# Jinja2 first — the fetch uses the rendered value, so the rendered value is what must pass.
|
|
if '{%' in url or '{{' in url:
|
|
try:
|
|
url = jinja_render(template_str=url).strip()
|
|
except Exception as e:
|
|
logger.error(f"URL '{url}' is not valid Jinja2? {str(e)}")
|
|
return False, "The URL contains invalid Jinja2 template syntax."
|
|
|
|
# 'source:' is our own meta prefix meaning "return the raw source"; it is not part of the
|
|
# URL that gets fetched. Must be removed before any hostname parsing happens - see step 1 above.
|
|
url = re.sub(r'^source:', '', url, flags=re.IGNORECASE).strip()
|
|
|
|
if re.match(r'^file:', url, re.IGNORECASE) and not strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
|
|
logger.warning(f"Fetch blocked: file:// access is disabled (ALLOW_FILE_URI) - '{url}'")
|
|
return False, "file:// type access is denied for security reasons."
|
|
|
|
# Checked here in its own right, not left to is_safe_valid_url()/is_url_private_or_parser_confused():
|
|
# a backslash is never legitimate in a URL, so it must be refused even when the operator has
|
|
# opted into private addresses with ALLOW_IANA_RESTRICTED_ADDRESSES (GHSA-rph4-96w6-q594).
|
|
if '\\' in url:
|
|
logger.warning(f"Fetch blocked: '{url}' contains a backslash (parser-differential SSRF vector).")
|
|
return False, f"Fetch blocked: '{url}' contains a parser-differential payload (backslash)."
|
|
|
|
if not is_safe_valid_url(url):
|
|
return False, "The URL is invalid or uses an unsupported protocol."
|
|
|
|
if not strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
|
if is_url_private_or_parser_confused(url):
|
|
return False, (
|
|
f"Fetch blocked: '{url}' resolves to a private/reserved IP address "
|
|
f"or contains a parser-differential payload. "
|
|
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
|
)
|
|
|
|
return True, ''
|
|
|
|
|
|
def validate_fetch_url(url):
|
|
"""is_fetch_url_allowed() as an assertion - raises ValueError with the reason.
|
|
|
|
Use at fetch entry points that should abort loudly (the message surfaces to the user as a
|
|
watch error or an HTTP 400). Blocks on DNS; from async code use validate_fetch_url_async().
|
|
"""
|
|
ok, reason = is_fetch_url_allowed(url)
|
|
if not ok:
|
|
raise ValueError(reason)
|
|
|
|
|
|
async def validate_fetch_url_async(url):
|
|
"""validate_fetch_url() with the DNS lookup pushed to a thread so the event loop isn't blocked."""
|
|
import asyncio
|
|
loop = asyncio.get_running_loop()
|
|
ok, reason = await loop.run_in_executor(None, is_fetch_url_allowed, url)
|
|
if not ok:
|
|
raise ValueError(reason)
|
|
|
|
|
|
def is_llm_api_base_safe(api_base):
|
|
"""SSRF guard for the LLM `api_base` setting (GHSA-jrxm-qjfh-g54f).
|
|
|
|
Returns (ok: bool, reason: str). Empty/None api_base is allowed (cloud providers
|
|
don't need it). When ALLOW_IANA_RESTRICTED_ADDRESSES=true the check is bypassed
|
|
so operators can intentionally point at local Ollama / vLLM / LM Studio.
|
|
|
|
Call this from EVERY write path that accepts `llm.api_base` from the user —
|
|
form validation, AJAX endpoints, and any future REST/import endpoint. The
|
|
existing call sites are forms.py (validateLLMApiBaseSafe) and
|
|
blueprint/settings/llm.py (both /models and /test).
|
|
"""
|
|
import os
|
|
from changedetectionio.strtobool import strtobool
|
|
from flask_babel import gettext
|
|
|
|
if not api_base or not api_base.strip():
|
|
return True, ''
|
|
|
|
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
|
return True, ''
|
|
|
|
api_base = api_base.strip()
|
|
|
|
if not is_safe_valid_url(api_base):
|
|
return False, gettext("API Base URL is not a valid http(s) URL.")
|
|
|
|
hostname = urlparse(api_base).hostname
|
|
if hostname and is_private_hostname(hostname):
|
|
return False, gettext(
|
|
"API Base URL resolves to a private, loopback, link-local or reserved "
|
|
"IP address and was blocked to prevent SSRF. To allow LLM endpoints on private networks "
|
|
"(e.g. a local Ollama server) set the environment variable "
|
|
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
|
)
|
|
|
|
return True, ''
|
|
|
|
|
|
def is_safe_valid_url(test_url):
|
|
from changedetectionio import strtobool
|
|
from changedetectionio.jinja2_custom import render as jinja_render
|
|
import os
|
|
import re
|
|
import validators
|
|
|
|
# Validate input type first - must be a non-empty string
|
|
if test_url is None:
|
|
logger.warning('URL validation failed: URL is None')
|
|
return False
|
|
|
|
if not isinstance(test_url, str):
|
|
logger.warning(f'URL validation failed: URL must be a string, got {type(test_url).__name__}')
|
|
return False
|
|
|
|
if not test_url.strip():
|
|
logger.warning('URL validation failed: URL is empty or whitespace only')
|
|
return False
|
|
|
|
# Per-request cache: same URL is often validated 2-3x per watchlist render (sort + display).
|
|
# Flask's g is scoped to one request and auto-cleared on teardown, so dynamic Jinja2 URLs
|
|
# like {{microtime()}} are always re-evaluated on the next request.
|
|
# Falls back gracefully when called outside a request context (e.g. background workers).
|
|
_cache_key = test_url
|
|
try:
|
|
from flask import g
|
|
_cache = g.setdefault('_url_validation_cache', {})
|
|
if _cache_key in _cache:
|
|
return _cache[_cache_key]
|
|
except RuntimeError:
|
|
_cache = None # No app context
|
|
|
|
allow_file_access = strtobool(os.getenv('ALLOW_FILE_URI', 'false'))
|
|
safe_protocol_regex = '^(http|https|ftp|file):' if allow_file_access else '^(http|https|ftp):'
|
|
|
|
# See https://github.com/dgtlmoon/changedetection.io/issues/1358
|
|
|
|
# Remove 'source:' prefix so we dont get 'source:javascript:' etc
|
|
# 'source:' is a valid way to tell us to return the source
|
|
|
|
r = re.compile('^source:', re.IGNORECASE)
|
|
test_url = r.sub('', test_url)
|
|
|
|
# Check the actual rendered URL in case of any Jinja markup
|
|
# Only run jinja_render when the URL actually contains Jinja2 syntax - creating a new
|
|
# ImmutableSandboxedEnvironment is expensive and is called once per watch per page load
|
|
if '{%' in test_url or '{{' in test_url:
|
|
try:
|
|
test_url = jinja_render(test_url)
|
|
except Exception as e:
|
|
logger.error(f'URL "{test_url}" is not correct Jinja2? {str(e)}')
|
|
return False
|
|
|
|
# Check query parameters and fragment
|
|
if re.search(r'[<>]', test_url):
|
|
logger.warning(f'URL "{test_url}" contains suspicious characters')
|
|
return False
|
|
|
|
# Reject backslashes — urllib's urlparse and urllib3's parse_url disagree on URLs containing
|
|
# a backslash (e.g. http://INTERNAL:8888\@PUBLIC/), which is the documented SSRF bypass in
|
|
# GHSA-rph4-96w6-q594. A backslash has no legitimate use in an HTTP URL, so block at add-time.
|
|
if '\\' in test_url:
|
|
logger.warning(f'URL "{test_url}" contains a backslash — rejected (parser-differential SSRF vector).')
|
|
return False
|
|
|
|
# Normalize URL encoding - handle both encoded and unencoded query parameters
|
|
test_url = normalize_url_encoding(test_url)
|
|
|
|
# Be sure the protocol is safe (no file, etcetc)
|
|
pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', safe_protocol_regex), re.IGNORECASE)
|
|
if not pattern.match(test_url.strip()):
|
|
logger.warning(f'URL "{test_url}" is not safe, aborting.')
|
|
return False
|
|
|
|
# If hosts that only contain alphanumerics are allowed ("localhost" for example)
|
|
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
|
|
try:
|
|
if not test_url.strip().lower().startswith('file:') and not validators.url(test_url, simple_host=allow_simplehost):
|
|
logger.warning(f'URL "{test_url}" failed validation, aborting.')
|
|
return False
|
|
except validators.ValidationError:
|
|
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
|
|
return False
|
|
|
|
if _cache is not None:
|
|
_cache[_cache_key] = True
|
|
return True
|