From ab0b85d088ac2e5f80fb6e50f596edc2ed0c0732 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Tue, 28 Oct 2025 13:24:37 +0100 Subject: [PATCH] Unify safe URL checking to the one function, strengthen tests and filters (#3564) --- changedetectionio/api/Import.py | 6 +- changedetectionio/api/Watch.py | 10 +-- changedetectionio/flask_app.py | 10 +-- changedetectionio/forms.py | 18 +--- changedetectionio/html_tools.py | 18 ---- changedetectionio/model/Watch.py | 10 +-- changedetectionio/store.py | 9 +- changedetectionio/templates/base.html | 2 +- changedetectionio/tests/test_jinja2.py | 16 +--- changedetectionio/tests/test_security.py | 21 +++-- changedetectionio/validate_url.py | 109 +++++++++++++++++++++++ 11 files changed, 151 insertions(+), 78 deletions(-) create mode 100644 changedetectionio/validate_url.py diff --git a/changedetectionio/api/Import.py b/changedetectionio/api/Import.py index 7294cc67..88626640 100644 --- a/changedetectionio/api/Import.py +++ b/changedetectionio/api/Import.py @@ -1,10 +1,9 @@ -import os from changedetectionio.strtobool import strtobool from flask_restful import abort, Resource from flask import request -import validators from functools import wraps from . import auth, validate_openapi_request +from ..validate_url import is_safe_valid_url def default_content_type(content_type='text/plain'): @@ -50,14 +49,13 @@ class Import(Resource): urls = request.get_data().decode('utf8').splitlines() added = [] - allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) for url in urls: url = url.strip() if not len(url): continue # If hosts that only contain alphanumerics are allowed ("localhost" for example) - if not validators.url(url, simple_host=allow_simplehost): + if not is_safe_valid_url(url): return f"Invalid or unsupported URL - {url}", 400 if dedupe and self.datastore.url_exists(url): diff --git a/changedetectionio/api/Watch.py b/changedetectionio/api/Watch.py index 399b7ad1..d63d3f4c 100644 --- a/changedetectionio/api/Watch.py +++ b/changedetectionio/api/Watch.py @@ -1,14 +1,12 @@ import os -from changedetectionio.strtobool import strtobool -from changedetectionio.html_tools import is_safe_url +from changedetectionio.validate_url import is_safe_valid_url from flask_expects_json import expects_json from changedetectionio import queuedWatchMetaData from changedetectionio import worker_handler from flask_restful import abort, Resource from flask import request, make_response, send_from_directory -import validators from . import auth import copy @@ -124,7 +122,7 @@ class Watch(Resource): return validation_error, 400 # XSS etc protection - if request.json.get('url') and not is_safe_url(request.json.get('url')): + if request.json.get('url') and not is_safe_valid_url(request.json.get('url')): return "Invalid URL", 400 watch.update(request.json) @@ -232,9 +230,7 @@ class CreateWatch(Resource): json_data = request.get_json() url = json_data['url'].strip() - # If hosts that only contain alphanumerics are allowed ("localhost" for example) - allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) - if not validators.url(url, simple_host=allow_simplehost): + if not is_safe_valid_url(url): return "Invalid or unsupported URL", 400 if json_data.get('proxy'): diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 94ce865f..399f568c 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -133,10 +133,10 @@ def get_socketio_path(): # Socket.IO will be available at {prefix}/socket.io/ return prefix -@app.template_global('is_safe_url') -def _is_safe_url(test_url): - from .html_tools import is_safe_url - return is_safe_url(test_url) +@app.template_global('is_safe_valid_url') +def _is_safe_valid_url(test_url): + from .validate_url import is_safe_valid_url + return is_safe_valid_url(test_url) @app.template_filter('format_number_locale') @@ -387,7 +387,7 @@ def changedetection_app(config=None, datastore_o=None): # We would sometimes get login loop errors on sites hosted in sub-paths # note for the future: - # if not is_safe_url(next): + # if not is_safe_valid_url(next): # return flask.abort(400) return redirect(url_for('watchlist.index')) diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index bb6c7a40..d169e8bc 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -28,11 +28,8 @@ from wtforms.utils import unset_value from wtforms.validators import ValidationError -from validators.url import url as url_validator - from changedetectionio.widgets import TernaryNoneBooleanField - # default # each select