fix: harden reflected metadata rendering

This commit is contained in:
Nick Sweeting 2026-06-14 00:06:35 -07:00
parent 6ae56b7366
commit 18e3580dcd
No known key found for this signature in database
12 changed files with 167 additions and 34 deletions

View File

@ -142,18 +142,22 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
process = result.process_record
process_display = "-"
if process:
process_url = html.escape(reverse("admin:machine_process_change", args=[process.id]), quote=True)
process_label = html.escape(get_process_link_label(process), quote=True)
process_display = f'''
<a href="{reverse("admin:machine_process_change", args=[process.id])}"
<a href="{process_url}"
style="color: #2563eb; text-decoration: none; font-family: ui-monospace, monospace; font-size: 12px;"
title="View process">{get_process_link_label(process)}</a>
title="View process">{process_label}</a>
'''
machine_display = "-"
if process and process.machine_id:
machine_url = html.escape(reverse("admin:machine_machine_change", args=[process.machine_id]), quote=True)
machine_label = html.escape(str(process.machine.hostname or ""), quote=True)
machine_display = f'''
<a href="{reverse("admin:machine_machine_change", args=[process.machine_id])}"
<a href="{machine_url}"
style="color: #2563eb; text-decoration: none; font-size: 12px;"
title="View machine">{process.machine.hostname}</a>
title="View machine">{machine_label}</a>
'''
# Truncate output for display
@ -176,9 +180,13 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
else:
output_link = build_snapshot_url(snapshot_id, "", config=config)
output_link_attr = html.escape(output_link, quote=True)
# Get version - try cmd_version field
version = result.cmd_version if result.cmd_version else "-"
version = html.escape(str(result.cmd_version if result.cmd_version else "-"), quote=True)
plugin_text = html.escape(str(result.plugin or ""), quote=True)
status_text = html.escape(str(status), quote=True)
pwd_text = html.escape(str(result.pwd or "-"), quote=True)
# Unique ID for this row's expandable output
row_id = f"output_{idx}_{str(result.id)[:8]}"
@ -195,18 +203,18 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<td style="padding: 10px 12px; white-space: nowrap;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: {color}; background: {bg};">{status}</span>
color: {color}; background: {bg};">{status_text}</span>
</td>
<td style="padding: 10px 12px; white-space: nowrap; font-size: 20px;" title="{result.plugin}">
<td style="padding: 10px 12px; white-space: nowrap; font-size: 20px;" title="{plugin_text}">
{icon}
</td>
<td style="padding: 10px 12px; font-weight: 500; color: #334155;">
<a href="{output_link}" target="_blank"
<a href="{output_link_attr}" target="_blank"
style="color: #334155; text-decoration: none;"
title="View output fullscreen"
onmouseover="this.style.color='#2563eb'; this.style.textDecoration='underline';"
onmouseout="this.style.color='#334155'; this.style.textDecoration='none';">
{result.plugin}
{plugin_text}
</a>
</td>
<td style="padding: 10px 12px; max-width: 280px;">
@ -233,7 +241,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
</td>
<td style="padding: 10px 8px; white-space: nowrap;">
<div style="display: flex; gap: 4px;">
<a href="{output_link}" target="_blank"
<a href="{output_link_attr}" target="_blank"
style="padding: 4px 8px; background: #f1f5f9; border-radius: 4px; color: #475569; text-decoration: none; font-size: 11px;"
title="View output">📄</a>
<a href="{reverse("admin:core_archiveresult_change", args=[result.id])}"
@ -252,7 +260,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<div style="font-size: 11px; color: #64748b; margin-bottom: 8px;">
<span style="margin-right: 16px;"><b>ID:</b> <code>{str(result.id)}</code></span>
<span style="margin-right: 16px;"><b>Version:</b> <code>{version}</code></span>
<span style="margin-right: 16px;"><b>PWD:</b> <code>{result.pwd or "-"}</code></span>
<span style="margin-right: 16px;"><b>PWD:</b> <code>{pwd_text}</code></span>
</div>
<div style="font-size: 11px; color: #64748b; margin-bottom: 8px;">
<b>Output:</b>

View File

@ -36,7 +36,7 @@ from archivebox.misc.util import (
to_json,
ts_to_date_str,
urlencode,
htmlencode,
htmldecode,
sanitize_html_text,
urldecode,
validate_url,
@ -3434,8 +3434,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
context = {
**self.to_dict(extended=True),
"snapshot": self,
"title": htmlencode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
"url_str": htmlencode(urldecode(self.base_url)),
"title": htmldecode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
"url_str": htmldecode(urldecode(self.base_url)),
"archive_url": urlencode(f"warc/{self.timestamp}" or (self.domain if is_archived else "")) or "about:blank",
"extension": self.extension or "html",
"tags": self.tags_str() or "untagged",

View File

@ -560,6 +560,17 @@ def snapshot_index_row(context, link) -> str:
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
"</span>"
)
elif "_public_preview_paths" in link.__dict__:
preview_paths = list(getattr(link, "_public_preview_paths", []) or [])
if preview_paths:
preview_urls = [build_snapshot_url(snapshot_id, path, request=request, config=config) for path in preview_paths]
preview_html = (
f'<img src="{escape(preview_urls[0])}" '
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
)
else:
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
else:
preview_html = (
f'<img src="{escape(screenshot_plugin_url)}" '

View File

@ -51,7 +51,7 @@ from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.util import (
base_url,
filter_queryset_by_uuid_substring,
htmlencode,
htmldecode,
sanitize_html_text,
ts_to_date_str,
urldecode,
@ -441,7 +441,7 @@ class SnapshotView(View):
"progress_endpoint": progress_endpoint("snapshot", snapshot.id),
"url": snapshot.url,
"archive_path": snapshot.archive_path_from_db,
"title": htmlencode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
"title": htmldecode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
"extension": snapshot.extension or "html",
"tags": snapshot.tags_str() or "untagged",
"size": printable_filesize(output_size) if output_size else "",
@ -1226,6 +1226,7 @@ class PublicIndexView(ListView):
snapshots = list(context.get("object_list") or ())
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
tag_names_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
preview_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
progress_by_snapshot: dict[str, dict[str, int]] = {
str(snapshot.id): {
"total": 0,
@ -1246,12 +1247,18 @@ class PublicIndexView(ListView):
):
tag_names_by_snapshot[str(snapshot_id)].append(tag_name)
for snapshot_id, plugin, status in (
preview_candidates = {
"screenshot": ("screenshot.png",),
"chrome_extension_screenshot": ("screenshot-1.png", "screenshot.png"),
"favicon": ("favicon.ico",),
}
for snapshot_id, plugin, status, output_files in (
ArchiveResult.objects.filter(
snapshot_id__in=icons_by_snapshot.keys(),
)
.exclude(plugin="")
.values_list("snapshot_id", "plugin", "status")
.values_list("snapshot_id", "plugin", "status", "output_files")
.iterator(chunk_size=1000)
):
snapshot_key = str(snapshot_id)
@ -1260,6 +1267,11 @@ class PublicIndexView(ListView):
if status == ArchiveResult.StatusChoices.SUCCEEDED:
icons_by_snapshot[snapshot_key].add(plugin)
progress["succeeded"] += 1
if plugin in preview_candidates and isinstance(output_files, dict):
for filename in preview_candidates[plugin]:
file_info = output_files.get(filename)
if file_info and int((file_info or {}).get("size") or 0) > 0:
preview_paths_by_snapshot[snapshot_key].append(f"{plugin}/{filename}")
elif status == ArchiveResult.StatusChoices.FAILED:
progress["failed"] += 1
elif status == ArchiveResult.StatusChoices.STARTED:
@ -1275,6 +1287,7 @@ class PublicIndexView(ListView):
snapshot._icons_progress_stats = progress_by_snapshot.get(str(snapshot.id), {})
snapshot.num_outputs_cached = snapshot._icons_progress_stats.get("succeeded", 0)
snapshot._tags_str_cached = ",".join(tag_names_by_snapshot.get(str(snapshot.id), []))
snapshot._public_preview_paths = preview_paths_by_snapshot.get(str(snapshot.id), [])
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
context["object_list"] = snapshots
return context

View File

@ -656,18 +656,22 @@
return url
}
function replaceRegionFromDocument(doc, selector) {
const next = doc.querySelector(selector)
const current = document.querySelector(selector)
if (next && current) current.innerHTML = next.innerHTML
}
async function renderNow() {
const url = buildListUrl()
const response = await fetch(url, {headers: {'X-Requested-With': 'XMLHttpRequest'}, signal: controller.signal})
const html = await response.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
const nextList = doc.querySelector('.public-snapshot-list')
const currentList = document.querySelector('.public-snapshot-list')
if (nextList && currentList) {
currentList.replaceWith(nextList)
initPublicSearchMode()
setSearchLoading(controller, resultVersion ? 'Searching... ' + resultVersion + ' found' : 'Searching...')
}
replaceRegionFromDocument(doc, '.public-snapshot-count')
replaceRegionFromDocument(doc, '#table-bookmarks thead th:nth-child(3)')
replaceRegionFromDocument(doc, '#table-bookmarks tbody')
replaceRegionFromDocument(doc, '.public-pagination')
setSearchLoading(controller, resultVersion ? 'Searching... ' + resultVersion + ' found' : 'Searching...')
}
const listUrl = buildListUrl()

View File

@ -1151,7 +1151,7 @@
</div>
<div class="header-title-line header-toggle-trigger">
<img src="{% snapshot_url snapshot 'favicon/favicon.ico' %}" onerror="this.onerror=null;this.src=&quot;data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'&gt;&lt;circle cx='10' cy='10' r='7.25'/&gt;&lt;ellipse cx='10' cy='10' rx='3.25' ry='7.25'/&gt;&lt;line x1='2.75' y1='10' x2='17.25' y2='10'/&gt;&lt;/svg&gt;&quot;" alt="Favicon" class="favicon"/>
<span class="header-title-text">{{title|truncatechars:120|safe}}</span>
<span class="header-title-text">{{title|truncatechars:120}}</span>
{% if title_tags %}
<span class="header-tags">
<span class="tag-pill permission-pill">{{ snapshot_permissions_icon }} {{ snapshot_permissions }}</span>

View File

@ -479,6 +479,15 @@ class TestPublicIndexSearch:
assert response.status_code == 200
assert b"archivebox-search-stream-status" in response.content
@override_settings(PUBLIC_INDEX=True)
def test_public_search_stream_preserves_search_form_dom(self, client, public_snapshot):
response = client.get("/public/", {"q": "Public Example"}, HTTP_HOST=WEB_HOST)
assert response.status_code == 200
assert b"replaceRegionFromDocument(doc, '.public-snapshot-count')" in response.content
assert b"replaceRegionFromDocument(doc, '#table-bookmarks tbody')" in response.content
assert b"currentList.replaceWith(nextList)" not in response.content
@override_settings(PUBLIC_INDEX=True)
def test_public_search_stream_populates_public_results_cache(self, client, public_snapshot):
search_params = {"q": "Public Example", "search_mode": "meta"}
@ -581,12 +590,21 @@ class TestPublicIndexSearch:
assert response.status_code == 200
content = response.content.decode()
assert "screenshot/screenshot.png" in content
first = content.index("chrome_extension_screenshot/screenshot-1.png")
second = content.index("chrome_extension_screenshot/screenshot.png")
assert first < second
assert "screenshot/screenshot.png" not in content
assert "chrome_extension_screenshot/screenshot-2.png" not in content
@override_settings(PUBLIC_INDEX=True)
def test_public_index_snapshot_without_preview_renders_placeholder(self, client, public_snapshot):
response = client.get("/public/", HTTP_HOST=WEB_HOST)
assert response.status_code == 200
content = response.content.decode()
assert "snapshot-preview-empty" in content
assert "screenshot/screenshot.png" not in content
@override_settings(PUBLIC_INDEX=True)
def test_public_index_pending_snapshot_uses_small_preview_spinner(self, client, crawl):
from archivebox.core.models import Snapshot

View File

@ -71,6 +71,72 @@ def test_snapshot_admin_archive_results_escape_extractor_output(admin_client, sn
assert b"&lt;img src=x onerror=&quot;window.__archivebox_archiveresult_xss__=1&quot;&gt;" in body
def test_snapshot_admin_archive_result_table_escapes_legacy_string_fields(admin_client, snapshot):
from uuid import uuid4
from archivebox.core.models import ArchiveResult
from archivebox.machine.models import Binary, Machine, Process
machine = Machine.objects.create(
guid=f"xss-machine-{uuid4()}",
hostname='<script id="machine-xss">x</script>',
hw_in_docker=False,
hw_in_vm=False,
hw_manufacturer="Test",
hw_product="Test Product",
hw_uuid=f"xss-hw-{uuid4()}",
os_arch="arm64",
os_family="darwin",
os_platform="macOS",
os_release="14.0",
os_kernel="Darwin",
stats={},
config={},
)
binary = Binary.objects.create(
machine=machine,
name="staticfile",
abspath="/usr/bin/staticfile",
version='<b id="version-xss">v</b>',
binprovider="env",
binproviders="env",
status=Binary.StatusChoices.INSTALLED,
)
process = Process.objects.create(
machine=machine,
binary=binary,
process_type=Process.TypeChoices.HOOK,
pwd='/tmp/archivebox"><script id="pwd-xss">x</script>',
cmd=["staticfile"],
status=Process.StatusChoices.EXITED,
)
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="staticfile",
hook_name="on_Snapshot__00_staticfile.py",
process=process,
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_files={'evil"><script id="file-xss">x</script>.txt': {"size": 12, "mimetype": "text/plain"}},
output_str='staticfile/evil"><script id="file-xss">x</script>.txt',
)
ArchiveResult.objects.filter(pk=result.pk).update(plugin='<script id="plugin-xss">x</script>')
response = admin_client.get(reverse("admin:core_snapshot_change", args=[snapshot.pk]), HTTP_HOST=ADMIN_TEST_HOST)
body = response.content
assert response.status_code == 200
assert b'<script id="machine-xss">' not in body
assert b'<script id="version-xss">' not in body
assert b'<script id="pwd-xss">' not in body
assert b'<script id="file-xss">' not in body
assert b'<script id="plugin-xss">' not in body
assert b"&lt;script id=&quot;machine-xss&quot;&gt;" in body
assert b"&lt;b id=&quot;version-xss&quot;&gt;v&lt;/b&gt;" in body
assert b"&lt;script id=&quot;pwd-xss&quot;&gt;" in body
assert b"&lt;script id=&quot;file-xss&quot;&gt;" in body
assert b"&lt;script id=&quot;plugin-xss&quot;&gt;" in body
def test_snapshot_changelist_bulk_permissions_action_updates_selected_snapshots(client, admin_user, crawl, snapshot):
client.force_login(admin_user)
url = reverse("admin:core_snapshot_changelist")

View File

@ -282,7 +282,7 @@ class TestPublicIndex:
@override_settings(PUBLIC_INDEX=True)
def test_public_snapshot_surfaces_escape_legacy_raw_title_and_tag_values(self, client, admin_user):
from archivebox.core.models import Snapshot, Tag
from archivebox.core.models import ArchiveResult, Snapshot, Tag
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.create(urls="https://public-xss.example", created_by=admin_user, config={"PERMISSIONS": "public"})
@ -296,11 +296,19 @@ class TestPublicIndex:
snapshot.tags.add(tag)
snapshot_archive_path = snapshot.archive_path
title_payload = "</script><script id=public-title-xss>window.__archivebox_public_title_xss__=1</script>"
title_payload = "Legacy title 1 < 2 & 3 </script><script id=public-title-xss>window.__archivebox_public_title_xss__=1</script>"
tag_payload = "</script><script id=public-tag-xss>window.__archivebox_public_tag_xss__=1</script>"
url_payload = "https://public-xss.example/</script><script id=public-url-xss>window.__archivebox_public_url_xss__=1</script>"
filename_payload = 'evil"><script id=public-file-xss>window.__archivebox_public_file_xss__=1</script>.txt'
Snapshot.objects.filter(pk=snapshot.pk).update(title=title_payload, url=url_payload)
Tag.objects.filter(pk=tag.pk).update(name=tag_payload)
ArchiveResult.objects.create(
snapshot=snapshot,
plugin="staticfile",
hook_name="on_Snapshot__00_staticfile.py",
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_files={filename_payload: {"size": 12, "mimetype": "text/plain"}},
)
public_index = client.get("/public/", HTTP_HOST=WEB_TEST_HOST)
snapshot_detail = client.get(f"/{snapshot_archive_path}/index.html", HTTP_HOST=WEB_TEST_HOST)
@ -311,9 +319,13 @@ class TestPublicIndex:
assert b"<script id=public-title-xss>" not in response.content
assert b"<script id=public-tag-xss>" not in response.content
assert b"<script id=public-url-xss>" not in response.content
assert b"<script id=public-file-xss>" not in response.content
assert b"&lt;/script&gt;&lt;script id=public-tag-xss&gt;" in response.content
assert b"&lt;/script&gt;&lt;script id=public-title-xss&gt;" in public_index.content
assert b"window.__archivebox_public_title_xss__=1" in snapshot_detail.content
assert b"Legacy title 1 &lt; 2 &amp; 3" in snapshot_detail.content
assert b"Legacy title 1 &amp;lt; 2 &amp;amp; 3" not in snapshot_detail.content
assert b"&lt;script id=public-file-xss&gt;" in snapshot_detail.content
def test_direct_snapshot_urls_allow_unlisted_but_not_private_for_guests(self, client, admin_user):
from archivebox.core.models import Snapshot

View File

@ -7,6 +7,7 @@ ARCHIVEBOX_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKSPACE_DIR="$(cd "${ARCHIVEBOX_REPO}/.." && pwd)"
PYPI_USERNAME="${PYPI_USERNAME:-__token__}"
PYPI_WAIT_ATTEMPTS="${PYPI_WAIT_ATTEMPTS:-90}"
DOCKER_IMAGE_REPOS="${DOCKER_IMAGE_REPOS:-archivebox/archivebox ghcr.io/archivebox/archivebox}"
cd "${WORKSPACE_DIR}"

View File

@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.35rc28",
"version": "0.9.35rc29",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {

View File

@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.35rc28"
version = "0.9.35rc29"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@ -79,9 +79,9 @@ dependencies = [
### Extractor dependencies (optional binaries detected at runtime via shutil.which)
### Binary/Package Management
"abxbus==2.5.10", # EventBus API
"abxpkg>=1.11.210", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.11.213", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.11.213", # shared ArchiveBox downloader package with blocking install preflight
"abxpkg>=1.11.211", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.11.214", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.11.214", # shared ArchiveBox downloader package with blocking install preflight
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
]