mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-28 16:26:42 +00:00
Also solves some of GHSA-56fq-63vj-9992 Add-watch-UI should be POST/CSRF protected
211 lines
9.7 KiB
Python
211 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""The Add-Watch page's browser picker.
|
|
|
|
The live preview needs a browser that can render screenshots + xpath element data, and
|
|
whatever rendered the preview is what the saved watch must check with - otherwise you
|
|
visually pick an element with Chrome and the watch then re-checks it with the plain HTTP
|
|
client. These tests cover the offered list, the server-side gate, and that the browser
|
|
which rendered a parked snapshot wins over the posted form.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
from flask import url_for
|
|
|
|
|
|
def _datastore(client):
|
|
return client.application.config.get('DATASTORE')
|
|
|
|
|
|
def test_hidden_without_a_live_preview_browser(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
|
"""No capable browser -> the sidebar link is hidden and the route bounces.
|
|
|
|
One seam is patched deliberately: has_visual_browser() (page + sidebar gate) and
|
|
default_visual_browser() both resolve through list_visual_browser_choices(), so the
|
|
gate can't disagree with what the picker would have offered.
|
|
"""
|
|
from changedetectionio.blueprint.add_watch_ui import browser_config
|
|
monkeypatch.setattr(browser_config, 'list_visual_browser_choices', lambda datastore: [])
|
|
|
|
# Sidebar link is not rendered
|
|
res = client.get(url_for('watchlist.index'))
|
|
assert url_for('add_watch_ui.add_watch_ui_index').encode() not in res.data
|
|
|
|
# Direct navigation is bounced back to the watch list
|
|
res = client.get(url_for('add_watch_ui.add_watch_ui_index'), follow_redirects=True)
|
|
assert b'name="fetch_backend"' not in res.data
|
|
assert b'needs an interactive browser' in res.data
|
|
|
|
|
|
def test_shown_when_a_live_preview_browser_exists(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
|
"""A capable browser -> the sidebar link is offered and the page serves its picker."""
|
|
from changedetectionio.blueprint.add_watch_ui import browser_config
|
|
monkeypatch.setattr(browser_config, 'list_visual_browser_choices',
|
|
lambda datastore: [('html_webdriver', 'WebDriver Chrome/Javascript')])
|
|
|
|
res = client.get(url_for('watchlist.index'))
|
|
assert url_for('add_watch_ui.add_watch_ui_index').encode() in res.data
|
|
|
|
res = client.get(url_for('add_watch_ui.add_watch_ui_index'))
|
|
assert res.status_code == 200
|
|
assert b'name="fetch_backend"' in res.data
|
|
assert b'value="html_webdriver"' in res.data
|
|
# The system default resolves to html_requests (no preview), so a real browser is preselected
|
|
assert b'checked' in res.data
|
|
|
|
|
|
def test_browser_picker_lists_only_live_preview_capable(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
|
"""Capable browsers are offered; the plain HTTP client never is."""
|
|
from changedetectionio.blueprint.add_watch_ui import browser_config
|
|
|
|
# html_webdriver is only preview-capable when it resolves to playwright/puppeteer, so
|
|
# pretend it does (a WEBDRIVER_URL-only deployment resolves it to selenium, which can't).
|
|
monkeypatch.setattr(browser_config, 'is_visual_capable',
|
|
lambda name, datastore: name == 'html_webdriver')
|
|
|
|
res = client.get(url_for('add_watch_ui.add_watch_ui_index'))
|
|
assert res.status_code == 200
|
|
assert b'name="fetch_backend"' in res.data
|
|
assert b'value="html_webdriver"' in res.data
|
|
# The plain HTTP client can't render a preview, so it is not an option
|
|
assert b'value="html_requests"' not in res.data
|
|
# The system default resolves to html_requests here, so it is listed but disabled
|
|
assert b'value="system"' in res.data
|
|
assert b'disabled' in res.data
|
|
|
|
|
|
def test_snapshot_refuses_browser_that_cannot_preview(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
|
"""/snapshot won't spend a fetch on a browser that produces no screenshot."""
|
|
from changedetectionio.blueprint.add_watch_ui import browser_config
|
|
monkeypatch.setattr(browser_config, 'is_visual_capable', lambda name, datastore: False)
|
|
|
|
snapshot_url = url_for('add_watch_ui.add_watch_ui_snapshot')
|
|
|
|
# Nothing capable, and no explicit browser asked for -> nothing to preview with
|
|
res = client.post(snapshot_url, data={'url': 'https://example.com'})
|
|
assert res.status_code == 400
|
|
assert b'No interactive browser' in res.data
|
|
|
|
# Explicitly asking for a browser that can't preview is refused just the same
|
|
res = client.post(snapshot_url, data={'url': 'https://example.com',
|
|
'fetch_backend': 'html_requests'})
|
|
assert res.status_code == 400
|
|
|
|
# A made-up name never resolves to a capable fetcher either (real capability lookup here)
|
|
monkeypatch.undo()
|
|
res = client.post(snapshot_url, data={'url': 'https://example.com',
|
|
'fetch_backend': '../../etc/passwd'})
|
|
assert res.status_code == 400
|
|
res = client.post(snapshot_url, data={'url': 'https://example.com',
|
|
'fetch_backend': 'os'})
|
|
assert res.status_code == 400
|
|
|
|
|
|
def test_snapshot_is_post_only(client, live_server, measure_memory_usage, datastore_path):
|
|
"""A GET must not reach the endpoint at all.
|
|
|
|
/snapshot drives a real server-side browser fetch and hands the rendered result back in
|
|
the response (GHSA-56fq-63vj-9992). As a GET that is reachable by anything that can make
|
|
the operator's browser issue a request - an <img>/<iframe>/link from another site - with
|
|
no CSRF token in play. POST-only + CSRFProtect means only our own page can trigger it.
|
|
"""
|
|
# Method mismatch surfaces as 404 here rather than 405
|
|
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot') + '?url=https://example.com')
|
|
assert res.status_code in (404, 405)
|
|
|
|
|
|
def test_submit_rejects_unknown_fetcher(client, live_server, measure_memory_usage, datastore_path):
|
|
"""A posted browser is checked server side, so a doctored form can't pin a junk fetcher."""
|
|
datastore = _datastore(client)
|
|
test_url = url_for('test_endpoint', _external=True)
|
|
|
|
for bad in ('os', 'html_requests\n', '{{7*7}}', '../../etc/passwd', 'html_nope'):
|
|
before = len(datastore.data['watching'])
|
|
res = client.post(
|
|
url_for("ui.ui_views.form_quick_watch_add"),
|
|
data={"url": test_url, "fetch_backend": bad},
|
|
follow_redirects=True
|
|
)
|
|
assert res.status_code == 200
|
|
assert b"Watch added" not in res.data
|
|
assert len(datastore.data['watching']) == before, f"{bad!r} should not have created a watch"
|
|
|
|
|
|
def test_submit_still_accepts_any_installed_fetcher(client, live_server, measure_memory_usage, datastore_path):
|
|
"""This endpoint is shared with the watch-list quick-add, which may legitimately name a
|
|
backend that can't render a live preview (restock tests add watches with html_requests) -
|
|
only *offering* it on the Add-Watch page is restricted, not adding a watch with it."""
|
|
datastore = _datastore(client)
|
|
test_url = url_for('test_endpoint', _external=True)
|
|
|
|
res = client.post(
|
|
url_for("ui.ui_views.form_quick_watch_add"),
|
|
data={"url": test_url, "processor": "restock_diff", "fetch_backend": "html_requests"},
|
|
follow_redirects=True
|
|
)
|
|
assert b"Watch added" in res.data
|
|
|
|
uuid = next(iter(datastore.data['watching']))
|
|
assert datastore.data['watching'][uuid].get('fetch_backend') == 'html_requests'
|
|
|
|
|
|
def test_submit_saves_chosen_browser(client, live_server, measure_memory_usage, datastore_path):
|
|
"""The picked browser lands on the watch instead of leaving it on 'system'."""
|
|
datastore = _datastore(client)
|
|
test_url = url_for('test_endpoint', _external=True)
|
|
|
|
res = client.post(
|
|
url_for("ui.ui_views.form_quick_watch_add"),
|
|
data={"url": test_url, "fetch_backend": "html_webdriver"},
|
|
follow_redirects=True
|
|
)
|
|
assert b"Watch added" in res.data
|
|
|
|
uuid = next(iter(datastore.data['watching']))
|
|
assert datastore.data['watching'][uuid].get('fetch_backend') == 'html_webdriver'
|
|
|
|
|
|
def test_parked_snapshot_browser_wins_over_form(client, live_server, measure_memory_usage, datastore_path):
|
|
"""The browser that actually rendered the snapshot is the one the watch keeps.
|
|
|
|
/snapshot records it in the temporary watch's own watch.json; promoting the snapshot
|
|
must prefer that over whatever the form posted, since the parked screenshot and
|
|
element data came from it.
|
|
"""
|
|
datastore = _datastore(client)
|
|
test_url = url_for('test_endpoint', _external=True)
|
|
|
|
temp_uuid = '11111111-2222-3333-4444-555555555555'
|
|
temp_dir = datastore.get_temporary_watch_dir(temp_uuid)
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
with open(os.path.join(temp_dir, "watch.json"), 'w') as f:
|
|
json.dump({"fetch_backend": "html_webdriver"}, f)
|
|
|
|
res = client.post(
|
|
url_for("ui.ui_views.form_quick_watch_add"),
|
|
data={"url": test_url, "fetch_backend": "html_requests", "temporary_uuid": temp_uuid},
|
|
follow_redirects=True
|
|
)
|
|
assert b"Watch added" in res.data
|
|
|
|
uuid = next(iter(datastore.data['watching']))
|
|
assert datastore.data['watching'][uuid].get('fetch_backend') == 'html_webdriver'
|
|
# The snapshot dir was promoted into the watch, not left behind
|
|
assert not os.path.isdir(temp_dir)
|
|
|
|
|
|
def test_watchlist_quick_add_is_unaffected(client, live_server, measure_memory_usage, datastore_path):
|
|
"""The watch-list quick-add posts no browser at all - that still means 'system'."""
|
|
datastore = _datastore(client)
|
|
test_url = url_for('test_endpoint', _external=True)
|
|
|
|
res = client.post(
|
|
url_for("ui.ui_views.form_quick_watch_add"),
|
|
data={"url": test_url},
|
|
follow_redirects=True
|
|
)
|
|
assert b"Watch added" in res.data
|
|
|
|
uuid = next(iter(datastore.data['watching']))
|
|
assert datastore.data['watching'][uuid].get('fetch_backend') in (None, '', 'system')
|