mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-29 21:11:50 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d93dbe9a | |||
| bdf54ff33f | |||
| 00d26e3656 |
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
@@ -278,8 +279,28 @@ class WatchSingleHistory(Resource):
|
||||
if request.args.get('html'):
|
||||
content = watch.get_fetched_html(timestamp)
|
||||
if content:
|
||||
# XSS mitigation (GHSA-cgj8-g98g-4p9x): this is an API endpoint, not a
|
||||
# browser-rendered view. The bytes ARE HTML (that's what the caller asked
|
||||
# for) but a programmatic client doesn't need text/html — and serving
|
||||
# text/html lets attacker-planted <script> in a monitored site execute
|
||||
# in our origin if someone opens the URL in a browser.
|
||||
#
|
||||
# text/plain + explicit utf-8 + nosniff = browser shows inert text,
|
||||
# sniffing can't re-classify it as HTML, an absent charset can't be
|
||||
# auto-detected as UTF-7 (an alternative XSS vector). API clients
|
||||
# still get the raw bytes — they don't care about Content-Type.
|
||||
response = make_response(content, 200)
|
||||
response.mimetype = "text/html"
|
||||
response.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Include the timestamp in the download name so downloading multiple
|
||||
# snapshots doesn't collide. No extension — the stored bytes are
|
||||
# "whatever the fetcher captured" (HTML, JSON, XML, text…), so
|
||||
# claiming .html on the download would be a false content-type label
|
||||
# for non-HTML watches. The user/curl can rename if needed.
|
||||
# Strip to safe filename chars (timestamp is already validated as a
|
||||
# watch.history key — this is defense in depth against header injection).
|
||||
safe_ts = re.sub(r'[^0-9A-Za-z_-]', '', str(timestamp))[:32] or 'snapshot'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="snapshot-{safe_ts}"'
|
||||
else:
|
||||
response = make_response("No content found", 404)
|
||||
response.mimetype = "text/plain"
|
||||
|
||||
@@ -56,6 +56,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
@login_optionally_required
|
||||
def llm_get_models():
|
||||
from flask import request
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
provider = request.args.get('provider', '').strip()
|
||||
api_key = request.args.get('api_key', '').strip()
|
||||
api_base = request.args.get('api_base', '').strip()
|
||||
@@ -66,10 +67,29 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.debug("LLM model list: no provider specified, returning 400")
|
||||
return jsonify({'models': [], 'error': 'No provider specified'}), 400
|
||||
|
||||
# Fall back to the stored key if the user hasn't typed one yet
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM model list refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'models': [], 'error': reason}), 400
|
||||
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# Only substitute the stored api_key when api_base matches the stored
|
||||
# api_base. If the caller pointed at a different destination, refuse —
|
||||
# otherwise a CSRF / unauthenticated request can ship the operator's
|
||||
# long-lived provider key (sent as Authorization: Bearer …) to an
|
||||
# attacker-controlled URL.
|
||||
stored_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
stored_api_base = (stored_llm.get('api_base') or '').strip()
|
||||
if not api_key:
|
||||
api_key = (datastore.data['settings']['application'].get('llm') or {}).get('api_key', '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key")
|
||||
if api_base == stored_api_base:
|
||||
api_key = (stored_llm.get('api_key') or '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key (api_base matches saved)")
|
||||
elif api_base:
|
||||
logger.warning("LLM model list refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'models': [], 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
|
||||
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/',
|
||||
'openai_compatible': 'openai/'}
|
||||
@@ -115,15 +135,21 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
def llm_test():
|
||||
from flask import request
|
||||
from changedetectionio.llm.client import completion
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
|
||||
# Pull stored config as the fallback, then override with anything the
|
||||
# form-driven JS sent as query params. Lets users test config changes
|
||||
# without first hitting Save (matching how /settings/llm/models works).
|
||||
stored = datastore.data['settings']['application'].get('llm') or {}
|
||||
# Keep the raw request-supplied values around so we can detect whether
|
||||
# the caller explicitly steered api_base / api_key (credential-exfil guard below).
|
||||
req_api_key = (request.args.get('api_key') or '').strip()
|
||||
req_api_base = (request.args.get('api_base') or '').strip()
|
||||
stored_api_base = (stored.get('api_base') or '').strip()
|
||||
llm_cfg = {
|
||||
'model': (request.args.get('model') or stored.get('model', '')).strip(),
|
||||
'api_key': (request.args.get('api_key') or stored.get('api_key', '')).strip(),
|
||||
'api_base': (request.args.get('api_base') or stored.get('api_base', '')).strip(),
|
||||
'api_key': (req_api_key or stored.get('api_key', '')).strip(),
|
||||
'api_base': (req_api_base or stored_api_base).strip(),
|
||||
'provider_kind': (request.args.get('provider_kind') or stored.get('provider_kind', '')).strip(),
|
||||
'local_token_multiplier': request.args.get('local_token_multiplier') or stored.get('local_token_multiplier'),
|
||||
}
|
||||
@@ -140,6 +166,23 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.error("LLM connection test failed: no model configured")
|
||||
return jsonify({'ok': False, 'error': 'No model configured.'}), 400
|
||||
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM connection test refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'ok': False, 'error': reason}), 400
|
||||
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# If the caller specified an api_base that differs from the saved one but
|
||||
# did NOT supply a matching api_key, refuse to substitute the stored key.
|
||||
# Otherwise a CSRF / unauthenticated request can route the operator's
|
||||
# long-lived provider key to an attacker-controlled endpoint.
|
||||
if req_api_base and req_api_base != stored_api_base and not req_api_key:
|
||||
logger.warning("LLM connection test refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'ok': False, 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
|
||||
try:
|
||||
logger.debug(f"LLM connection test: sending test prompt to model={model!r}")
|
||||
# Reuse the same multiplier path the production calls use, so cloud providers
|
||||
@@ -181,7 +224,12 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.exception("LLM connection test full traceback:")
|
||||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||||
|
||||
@llm_blueprint.route("/clear", methods=['GET'])
|
||||
# Both clear endpoints accept POST only — GET would let an attacker fire them via
|
||||
# <img src="...">, wiping LLM configuration / cached summaries on a logged-in
|
||||
# operator's browser (GHSA-g36r-fm2p-87xm). Flask-WTF CSRFProtect enforces a
|
||||
# CSRF token on POST automatically; the template renders csrf_token() inside the
|
||||
# surrounding <form>.
|
||||
@llm_blueprint.route("/clear", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def llm_clear():
|
||||
logger.debug("LLM configuration cleared by user")
|
||||
@@ -190,7 +238,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
flash(gettext("AI / LLM configuration removed."), 'notice')
|
||||
return redirect(url_for('settings.settings_page') + '#ai')
|
||||
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['GET'])
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def llm_clear_summary_cache():
|
||||
import glob
|
||||
|
||||
@@ -163,9 +163,14 @@
|
||||
✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }}
|
||||
</span>
|
||||
|
||||
{# data-method="POST" tells modal.js to POST with the CSRF token instead of
|
||||
navigating — GET previously allowed <img>-based CSRF wipe (GHSA-g36r-fm2p-87xm).
|
||||
Stays as <a> because we're inside the outer settings <form> — nested forms are
|
||||
invalid HTML, so modal.js builds a body-level hidden form for the POST. #}
|
||||
<a href="{{ url_for('settings.llm.llm_clear') }}"
|
||||
class="pure-button button-xsmall"
|
||||
style="background:#c0392b;color:#fff;"
|
||||
data-method="POST"
|
||||
data-requires-confirm
|
||||
data-confirm-type="danger"
|
||||
data-confirm-title="{{ _('Remove AI / LLM configuration?') }}"
|
||||
@@ -189,9 +194,11 @@
|
||||
|
||||
<div class="pure-control-group" style="margin-top:1.2em; padding-top:1em; border-top:1px solid rgba(128,128,128,0.15);">
|
||||
<label style="color:#888; font-size:0.85em;">{{ _('Cache') }}</label>
|
||||
{# See comment above on data-method="POST"+modal.js (GHSA-g36r-fm2p-87xm). #}
|
||||
<a href="{{ url_for('settings.llm.llm_clear_summary_cache') }}"
|
||||
class="pure-button button-xsmall"
|
||||
style="background:#7f8c8d;color:#fff;"
|
||||
data-method="POST"
|
||||
data-requires-confirm
|
||||
data-confirm-type="warning"
|
||||
data-confirm-title="{{ _('Clear all summary cache?') }}"
|
||||
|
||||
@@ -584,6 +584,17 @@ def validate_url(test_url):
|
||||
raise ValidationError('Watch protocol is not permitted or invalid URL format')
|
||||
|
||||
|
||||
class validateLLMApiBaseSafe(object):
|
||||
"""Block private/loopback/reserved api_base values (SSRF) unless the operator
|
||||
has opted in via ALLOW_IANA_RESTRICTED_ADDRESSES=true."""
|
||||
|
||||
def __call__(self, form, field):
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
ok, reason = is_llm_api_base_safe(field.data)
|
||||
if not ok:
|
||||
raise ValidationError(reason)
|
||||
|
||||
|
||||
class ValidateSinglePythonRegexString(object):
|
||||
def __init__(self, message=None):
|
||||
self.message = message
|
||||
@@ -1112,7 +1123,7 @@ class globalSettingsLLMForm(Form):
|
||||
)
|
||||
llm_api_base = StringField(
|
||||
_l('API Base URL'),
|
||||
validators=[validators.Optional()],
|
||||
validators=[validators.Optional(), validateLLMApiBaseSafe()],
|
||||
render_kw={
|
||||
"placeholder": "http://localhost:11434 (Ollama / custom endpoints only)",
|
||||
"style": "width: 24em;",
|
||||
|
||||
@@ -187,6 +187,30 @@ $(document).ready(function() {
|
||||
confirmText: $element.attr('data-confirm-button') || 'Confirm',
|
||||
cancelText: $element.attr('data-cancel-button') || 'Cancel',
|
||||
onConfirm: function() {
|
||||
// data-method="POST" — build a body-level hidden form with the CSRF
|
||||
// token and submit it. Avoids nested-form HTML invalidity when the
|
||||
// anchor lives inside an outer <form> (e.g. settings tabs). The CSRF
|
||||
// token comes from the global `csrftoken` set in base.html.
|
||||
// GHSA-g36r-fm2p-87xm: anchors that mutate server state must not fire
|
||||
// on a bare GET, since <img src=...> CSRF relies on GET firing.
|
||||
const method = ($element.attr('data-method') || 'GET').toUpperCase();
|
||||
if (method === 'POST') {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = url;
|
||||
form.style.display = 'none';
|
||||
if (typeof csrftoken !== 'undefined' && csrftoken) {
|
||||
const tok = document.createElement('input');
|
||||
tok.type = 'hidden';
|
||||
tok.name = 'csrf_token';
|
||||
tok.value = csrftoken;
|
||||
form.appendChild(tok);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a link, navigate to the URL
|
||||
if ($element.is('a')) {
|
||||
window.location.href = url;
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import threading
|
||||
import uuid as uuid_module
|
||||
from flask import url_for
|
||||
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
||||
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, delete_all_watches
|
||||
import os
|
||||
|
||||
|
||||
@@ -653,6 +653,80 @@ def test_api_history_edge_cases(client, live_server, measure_memory_usage, datas
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_history_html_does_not_serve_as_text_html(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-cgj8-g98g-4p9x: GET /api/v1/watch/<uuid>/history/<timestamp>?html=true
|
||||
must not serve the stored snapshot with Content-Type: text/html. The bytes
|
||||
are an external site's HTML — if the response is labelled text/html, a
|
||||
<script> the attacker planted on that site executes in our origin when an
|
||||
operator opens the URL in a browser (stored XSS).
|
||||
|
||||
The fix is text/plain; charset=utf-8 + X-Content-Type-Options: nosniff so
|
||||
browsers render inert text and can't sniff back to HTML/UTF-7. API clients
|
||||
don't care about Content-Type and still receive the same bytes.
|
||||
|
||||
This test injects the snapshot directly via Watch.save_history_blob() and
|
||||
save_last_fetched_html() so we exercise the API endpoint's response
|
||||
shaping without depending on the live-fetch pipeline.
|
||||
"""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
watch_uuid = res.json.get('uuid')
|
||||
|
||||
# Plant a payload that would execute if the response were rendered as HTML.
|
||||
malicious_html = (
|
||||
"<html><body>"
|
||||
"<script>window.__CD_XSS_PROBE = 1</script>"
|
||||
"<img src=x onerror=\"window.__CD_XSS_PROBE = 1\">"
|
||||
"</body></html>"
|
||||
)
|
||||
ts = '1700000000'
|
||||
watch = live_server.app.config['DATASTORE'].data['watching'][watch_uuid]
|
||||
watch.save_history_blob(contents=malicious_html, timestamp=ts, snapshot_id=ts)
|
||||
watch.save_last_fetched_html(timestamp=ts, contents=malicious_html)
|
||||
|
||||
# The actual XSS-relevant assertion: how is the snapshot served?
|
||||
res = client.get(
|
||||
url_for("watchsinglehistory", uuid=watch_uuid, timestamp=ts) + '?html=true',
|
||||
headers={'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200, f"unexpected status {res.status_code}: {res.data!r}"
|
||||
|
||||
ctype = res.headers.get('Content-Type', '')
|
||||
assert 'text/html' not in ctype, \
|
||||
f"snapshot must not be served as text/html (got {ctype!r}) — see GHSA-cgj8-g98g-4p9x"
|
||||
# Explicit utf-8 closes the UTF-7 sniffing bypass — without a charset, some
|
||||
# browsers will auto-detect UTF-7 from byte patterns and a crafted snapshot
|
||||
# can still execute via `+ADw-script+AD4-...`
|
||||
assert 'charset=utf-8' in ctype.lower(), \
|
||||
f"Content-Type must pin charset=utf-8 to defeat UTF-7 sniffing XSS (got {ctype!r})"
|
||||
|
||||
nosniff = res.headers.get('X-Content-Type-Options', '')
|
||||
assert nosniff.lower() == 'nosniff', \
|
||||
f"X-Content-Type-Options: nosniff required to defeat MIME-sniffing (got {nosniff!r})"
|
||||
|
||||
# Download filename should include the timestamp so multiple snapshots from
|
||||
# the same watch don't overwrite each other on disk.
|
||||
disp = res.headers.get('Content-Disposition', '')
|
||||
assert 'attachment' in disp and ts in disp, \
|
||||
f"Content-Disposition should be attachment + per-timestamp filename (got {disp!r})"
|
||||
|
||||
# API contract: the raw bytes must still be the original HTML — programmatic
|
||||
# consumers depend on getting the stored snapshot back.
|
||||
assert b'<script>' in res.data, \
|
||||
"Response body must still contain the raw stored bytes (the API contract)"
|
||||
|
||||
# Cleanup
|
||||
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Test notification configuration edge cases.
|
||||
|
||||
@@ -351,3 +351,313 @@ def test_settings_form_preserves_api_key_when_submitted_blank(
|
||||
f"Blank PasswordField submission must not clear the existing API key (got '{saved_key}')"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF — api_base must reject private/loopback/reserved hosts (GHSA-jrxm-qjfh-g54f)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Hosts that is_private_hostname() must classify as restricted.
|
||||
# 169.254.169.254 is the cloud metadata service (AWS/GCP IMDSv1).
|
||||
_SSRF_PRIVATE_HOSTS = [
|
||||
'http://127.0.0.1:6379',
|
||||
'http://localhost:11434',
|
||||
'http://10.0.0.5:8080',
|
||||
'http://192.168.1.1',
|
||||
'http://169.254.169.254',
|
||||
]
|
||||
|
||||
|
||||
def test_llm_models_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/models must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm."""
|
||||
# Default state — protection ON
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={'provider': 'openai_compatible', 'api_base': bad},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
f"api_base={bad!r} should have been rejected by SSRF guard"
|
||||
body = res.get_json()
|
||||
assert body['models'] == []
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body['error'], \
|
||||
f"Error message should mention the env-var bypass: {body['error']!r}"
|
||||
# The raw attacker-controlled api_base must never be reflected back
|
||||
# (avoids XSS when JS renders the error into the DOM).
|
||||
assert bad not in body['error']
|
||||
|
||||
|
||||
def test_llm_test_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/test must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm.completion()."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={'model': 'openai/gpt-4', 'api_base': bad},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
f"api_base={bad!r} should have been rejected by SSRF guard"
|
||||
body = res.get_json()
|
||||
assert body['ok'] is False
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body['error']
|
||||
assert bad not in body['error']
|
||||
|
||||
|
||||
def test_llm_endpoints_allow_api_base_when_iana_bypass_enabled(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""When ALLOW_IANA_RESTRICTED_ADDRESSES=true the SSRF guard is bypassed so
|
||||
operators can intentionally point at a local Ollama / vLLM endpoint.
|
||||
We patch litellm so the test doesn't actually need a live model server —
|
||||
we only need to confirm the guard didn't short-circuit."""
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
||||
|
||||
# Stub get_valid_models so the call returns successfully without network.
|
||||
import litellm
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: ['llama3.2'])
|
||||
|
||||
# Supply api_key explicitly so we aren't tripped by the credential-exfil
|
||||
# guard (which refuses to substitute the stored key for a non-stored api_base).
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={'provider': 'openai_compatible',
|
||||
'api_base': 'http://127.0.0.1:11434',
|
||||
'api_key': 'sk-test-explicit'},
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
"With ALLOW_IANA_RESTRICTED_ADDRESSES=true, private api_base must be allowed"
|
||||
body = res.get_json()
|
||||
assert body['error'] is None
|
||||
assert body['models'], "Stubbed model list should be returned"
|
||||
|
||||
|
||||
def test_settings_form_rejects_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""The globalSettingsLLMForm validator must block private api_base values
|
||||
when ALLOW_IANA_RESTRICTED_ADDRESSES is not set, and must NOT persist them
|
||||
to the datastore."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
# Make sure no stale api_base exists from previous tests.
|
||||
ds.data['settings']['application'].pop('llm', None)
|
||||
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': '',
|
||||
'llm-llm_api_base': 'http://127.0.0.1:11434',
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
'requests-time_between_check-days': '0',
|
||||
'requests-time_between_check-hours': '0',
|
||||
'requests-time_between_check-minutes': '5',
|
||||
'requests-time_between_check-seconds': '0',
|
||||
'requests-time_between_check-weeks': '0',
|
||||
'requests-workers': '10',
|
||||
'requests-timeout': '60',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
# Form re-renders with the validation error — page itself returns 200.
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body, \
|
||||
"Settings page should surface the SSRF guard's bypass-env-var hint"
|
||||
|
||||
saved = ds.data['settings']['application'].get('llm', {}).get('api_base', '')
|
||||
assert saved != 'http://127.0.0.1:11434', \
|
||||
f"Private api_base must not have been persisted (got {saved!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential exfiltration — stored api_key must NOT be auto-substituted when
|
||||
# the caller points api_base at a different (potentially attacker-controlled)
|
||||
# endpoint. GHSA-g36r-fm2p-87xm.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_models_refuses_to_leak_stored_key_to_different_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""If the request supplies an api_base that differs from the saved one but
|
||||
omits api_key, the endpoint must refuse — otherwise CSRF can ship the
|
||||
stored Authorization: Bearer <key> to an attacker-controlled URL."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds) # stores CANARY_KEY, leaves api_base unset
|
||||
|
||||
# Patch litellm.get_valid_models so that if the guard ever lets us through
|
||||
# we'd see it called — and we can assert it wasn't.
|
||||
import litellm
|
||||
calls = []
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: calls.append(kwargs) or [])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={
|
||||
'provider': 'openai',
|
||||
'api_base': 'https://attacker.example/v1',
|
||||
# api_key intentionally omitted — this is the CSRF case
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
"Endpoint should refuse to substitute stored key to a mismatched api_base"
|
||||
body = res.get_json()
|
||||
assert 'api_key' in body['error'], \
|
||||
f"Error should call out that api_key is required: {body['error']!r}"
|
||||
assert calls == [], "litellm must not have been invoked at all"
|
||||
|
||||
|
||||
def test_llm_test_refuses_to_leak_stored_key_to_different_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Same guard on /settings/llm/test — attacker-supplied api_base + missing
|
||||
api_key must not result in the stored key being sent to that URL."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds) # stores CANARY_KEY, no stored api_base
|
||||
|
||||
calls = []
|
||||
# Patch the completion wrapper so we'd notice if litellm were invoked.
|
||||
import changedetectionio.llm.client as llm_client
|
||||
monkeypatch.setattr(llm_client, 'completion',
|
||||
lambda **kw: calls.append(kw) or ('', 0, 0, 0))
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={
|
||||
'model': 'gpt-4o-mini',
|
||||
'api_base': 'https://attacker.example/v1',
|
||||
# api_key intentionally omitted
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
body = res.get_json()
|
||||
assert body['ok'] is False
|
||||
assert 'api_key' in body['error']
|
||||
assert calls == [], "completion() must not have been invoked"
|
||||
|
||||
|
||||
def test_llm_models_allows_stored_key_when_api_base_matches_saved(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Regression: the legit UI flow (test saved config without retyping the key)
|
||||
must still work — i.e. when request api_base matches the stored api_base,
|
||||
the stored key IS substituted."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true') # so localhost passes SSRF
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
ds.data['settings']['application']['llm']['api_base'] = 'http://localhost:11434'
|
||||
|
||||
received = []
|
||||
import litellm
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: (received.append(kwargs), ['llama3.2'])[1])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={
|
||||
'provider': 'openai_compatible',
|
||||
'api_base': 'http://localhost:11434', # matches saved
|
||||
# api_key omitted — should fall back to stored CANARY_KEY
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.get_json()
|
||||
assert received and received[0].get('api_key') == CANARY_KEY, \
|
||||
"When api_base matches saved, the stored api_key should be used"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSRF — /clear and /clear-summary-cache must not mutate state on GET
|
||||
# (GHSA-g36r-fm2p-87xm). The <img src=...> CSRF vector relies on GET firing the
|
||||
# mutation; the production guard is "POST only + Flask-WTF CSRF token". The
|
||||
# test config disables WTF_CSRF_ENABLED, so we verify the GET vector by
|
||||
# asserting the mutation didn't happen, and verify POST routing by exercising
|
||||
# the legit confirm-then-POST flow.
|
||||
#
|
||||
# NB: the app registers a catch-all '/<path:filename>' static route, which
|
||||
# intercepts any GET that isn't claimed by a method-matching rule and returns
|
||||
# 404 — so we can't simply assert on status code. The behaviour test below is
|
||||
# the actual security property.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_clear_get_does_not_wipe_config(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The CSRF surface is GET → mutation. After this fix the endpoint is
|
||||
POST-only, so a GET must leave LLM config intact."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY
|
||||
|
||||
client.get(url_for('settings.llm.llm_clear'))
|
||||
|
||||
# Mutation must not have happened — that's what defeats <img src=...> CSRF.
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY, \
|
||||
"GET /settings/llm/clear must not wipe LLM config (CSRF guard)"
|
||||
|
||||
|
||||
def test_llm_clear_summary_cache_get_does_not_wipe_cache(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same property for the cache wipe endpoint — GET must not delete the
|
||||
change-summary-*.txt files the endpoint targets. To exercise the actual
|
||||
deletion path we have to create a real watch (so a real data_dir exists)
|
||||
and drop a real change-summary-*.txt inside it. POST should remove it;
|
||||
GET must not."""
|
||||
import os
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
|
||||
# Create a real watch — required to exercise llm_clear_summary_cache's
|
||||
# iteration over datastore.data['watching'].values().
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
res = client.post(
|
||||
'/api/v1/watch',
|
||||
data=json.dumps({'url': test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_token},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
uuid = res.json.get('uuid')
|
||||
|
||||
watch = ds.data['watching'][uuid]
|
||||
data_dir = watch.data_dir
|
||||
assert data_dir, "Watch must have a data_dir for this test to be meaningful"
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
summary_file = os.path.join(data_dir, 'change-summary-csrf-canary.txt')
|
||||
with open(summary_file, 'w') as f:
|
||||
f.write('do-not-delete-via-GET')
|
||||
|
||||
# GET must NOT trigger the wipe — this is the CSRF surface that was open
|
||||
# via <img src="/settings/llm/clear-summary-cache">.
|
||||
client.get(url_for('settings.llm.llm_clear_summary_cache'))
|
||||
assert os.path.exists(summary_file), \
|
||||
"GET on /settings/llm/clear-summary-cache must not invoke the cache wipe"
|
||||
|
||||
# Sanity check: POST does remove it — confirms our test actually exercises
|
||||
# the deletion path the GET test is guarding against.
|
||||
client.post(url_for('settings.llm.llm_clear_summary_cache'))
|
||||
assert not os.path.exists(summary_file), \
|
||||
"POST on /settings/llm/clear-summary-cache should remove change-summary-*.txt"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_llm_clear_via_post_still_works(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Confirm the legit confirm-then-POST flow still wipes LLM config."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY
|
||||
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
assert 'llm' not in ds.data['settings']['application']
|
||||
|
||||
@@ -437,7 +437,7 @@ def test_global_default_survives_llm_clear(
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_set_global_default(ds, 'Surviving prompt.')
|
||||
|
||||
res = client.get(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
assert ds.data['settings']['application'].get('llm_change_summary_default') == 'Surviving prompt.'
|
||||
|
||||
@@ -360,6 +360,12 @@ msgstr "Všechna oznámení ztlumena."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Všechna oznámení odtlumena."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr "AI / LLM konfigurace odstraněna."
|
||||
@@ -4165,6 +4171,17 @@ msgstr "Změnit jazyk"
|
||||
msgid "Change language"
|
||||
msgstr "Změnit jazyk"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Ano"
|
||||
|
||||
@@ -366,6 +366,12 @@ msgstr "Alle Benachrichtigungen stummgeschaltet."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Alle Benachrichtigungen entstummt."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4221,6 +4227,17 @@ msgstr "Sprache ändern"
|
||||
msgid "Change language"
|
||||
msgstr "Sprache ändern"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
@@ -358,6 +358,12 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4159,6 +4165,17 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -358,6 +358,12 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4159,6 +4165,17 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -366,6 +366,12 @@ msgstr "Todas las notificaciones silenciadas."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Todas las notificaciones activadas."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4245,6 +4251,17 @@ msgstr "Cambiar idioma"
|
||||
msgid "Change language"
|
||||
msgstr "Cambiar idioma"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
@@ -362,6 +362,12 @@ msgstr "Toutes les notifications sont désactivées."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Toutes les notifications sont activées."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4174,6 +4180,17 @@ msgstr "Changer de langue"
|
||||
msgid "Change language"
|
||||
msgstr "Changer de langue"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Oui"
|
||||
|
||||
@@ -360,6 +360,12 @@ msgstr "Tutte le notifiche disattivate."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Tutte le notifiche attivate."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4161,6 +4167,17 @@ msgstr "Cambia Lingua"
|
||||
msgid "Change language"
|
||||
msgstr "Cambia lingua"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sì"
|
||||
|
||||
@@ -362,6 +362,12 @@ msgstr "すべての通知をミュートしました。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "すべての通知のミュートを解除しました。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4202,6 +4208,17 @@ msgstr "言語の変更"
|
||||
msgid "Change language"
|
||||
msgstr "言語を変更"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "はい"
|
||||
|
||||
@@ -360,6 +360,12 @@ msgstr "모든 알림이 음소거되었습니다."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "모든 알림의 음소거가 해제되었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr "AI / LLM 설정이 제거되었습니다."
|
||||
@@ -4179,6 +4185,17 @@ msgstr "언어 변경"
|
||||
msgid "Change language"
|
||||
msgstr "언어 변경"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "예"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.55.3\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-15 18:31+0200\n"
|
||||
"POT-Creation-Date: 2026-05-19 10:29+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -357,6 +357,12 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4158,6 +4164,17 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -363,6 +363,12 @@ msgstr "Todas as notificações silenciadas."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Todas as notificações reativadas."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4217,6 +4223,17 @@ msgstr "Mudar Idioma"
|
||||
msgid "Change language"
|
||||
msgstr "Mudar idioma"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
@@ -367,6 +367,12 @@ msgstr "Tüm bildirimler sessize alındı."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Tüm bildirimlerin sesi açıldı."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4220,6 +4226,17 @@ msgstr "Dili Değiştir"
|
||||
msgid "Change language"
|
||||
msgstr "Dili değiştir"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Evet"
|
||||
|
||||
@@ -361,6 +361,12 @@ msgstr "Усі сповіщення вимкнено."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Усі сповіщення увімкнено."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4197,6 +4203,17 @@ msgstr "Змінити мову"
|
||||
msgid "Change language"
|
||||
msgstr "Змінити мову"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Так"
|
||||
|
||||
@@ -362,6 +362,12 @@ msgstr "所有通知已静音。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "所有通知已取消静音。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4165,6 +4171,17 @@ msgstr "切换语言"
|
||||
msgid "Change language"
|
||||
msgstr "切换语言"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
@@ -361,6 +361,12 @@ msgstr "所有通知已靜音。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "所有通知已取消靜音。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -4163,6 +4169,17 @@ msgstr "更改語言"
|
||||
msgid "Change language"
|
||||
msgstr "更改語言"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
@@ -80,6 +80,45 @@ def is_private_hostname(hostname):
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user