fix: secure snapshot output deletion handoff

This commit is contained in:
Nick Sweeting 2026-08-29 23:03:08 -07:00
parent b5560f77ff
commit c8ef576235
No known key found for this signature in database
7 changed files with 226 additions and 12 deletions

View File

@ -10,9 +10,12 @@ from pathlib import Path
from urllib.parse import quote
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.contrib.admin.actions import delete_selected
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.core.exceptions import PermissionDenied, SuspiciousOperation, ValidationError
from django.db.models import Count, Min, Prefetch, Q, Subquery, TextField, Window
from django.db.models.functions import Cast
from django.shortcuts import redirect
from django.urls import resolve, reverse
from django.utils import timezone
from django.utils.html import format_html
@ -543,10 +546,44 @@ class ArchiveResultAdmin(BaseModelAdmin):
def changelist_view(self, request, extra_context=None):
self.request = request
selected = request.GET.getlist(ACTION_CHECKBOX_NAME)
if request.method == "GET" and request.GET.get("action") == "delete_selected" and selected:
if not request.user.is_superuser:
raise PermissionDenied
if len(selected) > 100:
raise SuspiciousOperation("Too many ArchiveResults selected for deletion")
try:
queryset = self.get_queryset(request).filter(pk__in=selected)
if not queryset.exists():
snapshot = Snapshot.objects.only("id").filter(pk=request.GET.get("snapshot")).first()
return redirect(build_snapshot_url(str(snapshot.id), "index.html", request=request) if snapshot else request.path)
except (ValidationError, ValueError):
return redirect(request.path)
return delete_selected(self, request, queryset)
handoff_snapshot = (
request.GET.get("snapshot") if request.method == "POST" and request.GET.get("action") == "delete_selected" else None
)
if handoff_snapshot:
request.GET = request.GET.copy()
request.GET.clear()
request.META["QUERY_STRING"] = ""
try:
handoff_snapshot = Snapshot.objects.only("id").filter(pk=handoff_snapshot).first()
except (ValidationError, ValueError):
handoff_snapshot = None
saved_list_per_page = self.list_per_page
self.list_per_page = request.archivebox_config.SNAPSHOTS_PER_PAGE
try:
return super().changelist_view(request, extra_context)
response = super().changelist_view(request, extra_context)
if (
handoff_snapshot
and response.status_code in (301, 302)
and not ArchiveResult.objects.filter(
pk__in=request.POST.getlist(ACTION_CHECKBOX_NAME),
).exists()
):
return redirect(build_snapshot_url(str(handoff_snapshot.id), "index.html", request=request))
return response
finally:
self.list_per_page = saved_list_per_page

View File

@ -39,8 +39,8 @@ ADMIN_LOGIN_HINT_COOKIE = "archivebox_admin_logged_in"
def _admin_login_hint_cookie_domain(config) -> str | None:
"""Resolve the parent domain to scope the cross-subdomain login hint.
NOTE: this cookie carries only the single bit "user is logged in on
admin somewhere"; it MUST NOT be confused with the session cookie,
NOTE: this cookie carries only the single bit "a superuser is logged in
on admin somewhere"; it MUST NOT be confused with the session cookie,
which stays admin-host-scoped (see core/settings.py
SESSION_COOKIE_DOMAIN comment admin/web is a security boundary).
@ -88,6 +88,10 @@ def AdminCookieIsolationMiddleware(get_response):
def middleware(request):
response = get_response(request)
if request.path == "/admin" or request.path.startswith("/admin/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
config = request.__dict__.get("archivebox_config")
if config is None or config.SERVER_SECURITY_MODE == "auto":
from archivebox.config.common import get_request_config
@ -197,6 +201,21 @@ def ServerSecurityModeMiddleware(get_response):
config = get_request_config(request, resolve_plugins=False)
if config.USES_SUBDOMAIN_ROUTING and config.BASE_URL and request.method.upper() not in allowed_methods:
request_host, _request_port = split_host_port((request.get_host() or "").lower())
control_hosts = {
split_host_port(host)[0]
for host in (
get_base_host(config=config),
get_admin_host(config=config),
get_api_host(config=config),
get_web_host(config=config),
)
if host
}
if request_host not in control_hosts:
return HttpResponseForbidden("ArchiveBox is running with the control plane disabled on this host.")
if config.CONTROL_PLANE_ENABLED:
return get_response(request)
@ -319,7 +338,12 @@ def HostRoutingMiddleware(get_response):
return redirect(target)
response = get_response(request)
hint_cookie_domain = _admin_login_hint_cookie_domain(config)
if request.user.is_authenticated and not request.path.startswith("/admin/logout"):
if (
request.user.is_authenticated
and request.user.is_active
and request.user.is_superuser
and not request.path.startswith("/admin/logout")
):
response.set_cookie(
ADMIN_LOGIN_HINT_COOKIE,
"1",

View File

@ -3594,6 +3594,14 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"warc/",
)
user = getattr(request, "user", None)
can_delete_outputs = bool(
static_export_dir is None
and request is not None
and (
(user and user.is_authenticated and user.is_active and user.is_superuser)
or request.COOKIES.get("archivebox_admin_logged_in") == "1"
),
)
tag_widget = TagEditorWidget()
return {
"id": str(self.id),
@ -3626,7 +3634,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"related_years": related_years,
"loose_items": loose_items,
"failed_items": failed_items,
"can_delete_outputs": bool(user and user.is_authenticated and user.is_active and user.is_superuser),
"can_delete_outputs": can_delete_outputs,
"title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in sorted(tags, key=lambda tag: tag.name)],
"STATIC_EXPORT": static_export_dir is not None,
"STATIC_EXPORT_DIR": static_export_dir,

View File

@ -1732,7 +1732,7 @@
<a href="{{display_url}}" data-no-preview="1" title="Download output file" download>⬇️</a>
{% endif %}
{% if can_delete_outputs and result.result %}
<button type="button" data-no-preview="1" data-archive-result-ids="{{result.result_ids}}" data-delete-url="{% admin_base_url %}/admin/core/archiveresult/" title="Delete this output">×</button>
<button type="button" data-no-preview="1" data-archive-result-ids="{{result.result_ids}}" data-delete-url="{% admin_base_url %}/admin/core/archiveresult/" data-delete-handoff="1" data-delete-snapshot-id="{{snapshot.id}}" title="Delete this output">×</button>
{% endif %}
</div>
{% if display_path %}
@ -1819,7 +1819,6 @@
</div>
</header>
{% if can_delete_outputs %}<input type="hidden" id="delete-output-csrf" value="{{csrf_token}}">{% endif %}
{% if can_delete_outputs %}{% include "includes/output_delete_controls.html" %}{% endif %}
<script>

View File

@ -55,10 +55,20 @@
}
function deleteQueuedOutputs() {
const csrf = document.querySelector('#delete-output-csrf, input[name="csrfmiddlewaretoken"]')?.value
const deleteUrl = deleteOutputButtons.find((button) => button.dataset.deleteUrl)?.dataset.deleteUrl
if (!csrf || !deleteUrl) return window.alert('Delete failed: missing admin request data')
const button = deleteOutputButtons.find((candidate) => candidate.dataset.deleteUrl)
let deleteUrl = button?.dataset.deleteUrl
if (!deleteUrl) return window.alert('Delete failed: missing admin request data')
deleteOutputButtons.forEach((button) => button.disabled = true)
if (button.dataset.deleteHandoff) {
deleteUrl = new URL(deleteUrl, window.location.href)
deleteUrl.searchParams.set('action', 'delete_selected')
deleteUrl.searchParams.set('snapshot', button.dataset.deleteSnapshotId)
queuedOutputIds.forEach((id) => deleteUrl.searchParams.append('_selected_action', id))
window.location.assign(deleteUrl)
return
}
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value
if (!csrf) return window.alert('Delete failed: missing admin request data')
const body = new URLSearchParams({action: 'delete_selected', post: 'yes', csrfmiddlewaretoken: csrf})
queuedOutputIds.forEach((id) => body.append('_selected_action', id))
fetch(deleteUrl, {

View File

@ -16,6 +16,7 @@ from django.core.paginator import UnorderedObjectListWarning
from django.test import RequestFactory
from django.urls import reverse
from archivebox.core.middleware import ADMIN_LOGIN_HINT_COOKIE
from archivebox.tests.conftest import ADMIN_TEST_HOST
from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook, _snapshot_hook_name
@ -858,11 +859,13 @@ class TestSnapshotOutputDeletion:
assert f'data-archive-result-ids="{result.id}"' in html
assert 'title="Delete this output"' in html
assert 'data-delete-handoff="1"' in html
assert "const queuedOutputIds = new Set()" in html
assert "action: 'delete_selected'" in html
assert "window.location.assign(deleteUrl)" in html
assert "/admin/core/archiveresult/" in html
assert "[deleting]" in html
assert ">×</button>" in html
assert "delete-output-csrf" not in html
assert "[data-archive-result-ids]:hover" in html
assert "[data-archive-result-ids].delete-pending" in html
assert "button.classList.toggle('delete-pending', queued)" in html
@ -872,6 +875,55 @@ class TestSnapshotOutputDeletion:
assert "data-archive-result-ids" not in anonymous_html
assert 'title="Delete this output"' not in anonymous_html
request.COOKIES[ADMIN_LOGIN_HINT_COOKIE] = "1"
hinted_html = SnapshotView.render_live_index(request, snapshot).content.decode()
assert f'data-archive-result-ids="{result.id}"' in hinted_html
assert "delete-output-csrf" not in hinted_html
def test_snapshot_delete_handoff_requires_superuser_confirmation_then_uses_standard_admin_action(self, client, snapshot, admin_user):
from archivebox.core.models import ArchiveResult
result = self._create_output(snapshot)
delete_url = reverse("admin:core_archiveresult_changelist")
handoff_query = {
"action": "delete_selected",
ACTION_CHECKBOX_NAME: str(result.id),
"snapshot": str(snapshot.id),
}
logged_out = client.get(delete_url, handoff_query, HTTP_HOST=ADMIN_TEST_HOST)
assert logged_out.status_code == 302
assert ArchiveResult.objects.filter(pk=result.pk).exists()
staff_user = admin_user.__class__.objects.create_user(username="output-reviewer", password="testpassword", is_staff=True)
client.force_login(staff_user)
denied = client.get(delete_url, handoff_query, HTTP_HOST=ADMIN_TEST_HOST)
assert denied.status_code == 403
assert ArchiveResult.objects.filter(pk=result.pk).exists()
client.force_login(admin_user)
confirmation = client.get(delete_url, handoff_query, HTTP_HOST=ADMIN_TEST_HOST)
confirmation_html = confirmation.content.decode()
assert confirmation.status_code == 200
assert "Yes, Im sure" in confirmation_html
assert f'name="{ACTION_CHECKBOX_NAME}" value="{result.id}"' in confirmation_html
assert confirmation["X-Frame-Options"] == "DENY"
assert "frame-ancestors 'none'" in confirmation["Content-Security-Policy"]
assert ArchiveResult.objects.filter(pk=result.pk).exists()
confirmed = client.post(
f"{delete_url}?action=delete_selected&{ACTION_CHECKBOX_NAME}={result.id}&snapshot={snapshot.id}",
{
"action": "delete_selected",
"post": "yes",
ACTION_CHECKBOX_NAME: str(result.id),
},
HTTP_HOST=ADMIN_TEST_HOST,
)
assert confirmed.status_code == 302
assert not ArchiveResult.objects.filter(pk=result.pk).exists()
assert str(snapshot.id).replace("-", "")[-12:] in confirmed["Location"]
def test_batch_delete_removes_plugin_rows_files_and_refreshes_snapshot_size(self, client, snapshot, admin_user):
from archivebox.core.models import ArchiveResult

View File

@ -385,6 +385,90 @@ class TestUrlRouting:
assert (installed[0] / "sw.js").is_file()
return installed[0]
@pytest.mark.parametrize(
"mode",
["auto", "safe-subdomains-fullreplay", "safe-onedomain-nojsreplay", "unsafe-onedomain-noadmin", "danger-onedomain-fullreplay"],
)
def test_snapshot_output_delete_handoff_is_non_mutating_in_every_security_mode(self, mode: str) -> None:
self._run(
"""
ensure_admin_user()
snapshot = get_snapshot()
snapshot.config = {**snapshot.config, "PERMISSIONS": "public"}
snapshot.save(update_fields=["config"])
plugin = "security_delete_test"
output_path = Path(snapshot.output_dir) / plugin / "output.txt"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("security test", encoding="utf-8")
result, _ = ArchiveResult.objects.update_or_create(
snapshot=snapshot,
plugin=plugin,
defaults={
"hook_name": "on_Snapshot__99_security_delete_test.py",
"status": ArchiveResult.StatusChoices.SUCCEEDED,
"output_str": "output.txt",
"output_files": {"output.txt": {"size": 13, "mimetype": "text/plain"}},
"output_size": 13,
},
)
client = Client(enforce_csrf_checks=True)
assert client.login(username="testadmin", password="testpassword")
admin_host = get_admin_host()
admin_page = client.get("/admin/", HTTP_HOST=admin_host)
if SERVER_CONFIG.CONTROL_PLANE_ENABLED:
assert admin_page.status_code == 200
else:
assert admin_page.status_code == 403
assert admin_page["X-Frame-Options"] == "DENY"
assert "frame-ancestors 'none'" in admin_page["Content-Security-Policy"]
snapshot_host = get_snapshot_host(str(snapshot.id)) if SERVER_CONFIG.USES_SUBDOMAIN_ROUTING else get_base_host()
snapshot_path = "/index.html" if SERVER_CONFIG.USES_SUBDOMAIN_ROUTING else f"/snapshot/{snapshot.id}/index.html"
detail = client.get(snapshot_path, HTTP_HOST=snapshot_host)
html = response_body(detail).decode("utf-8", "ignore")
assert detail.status_code == 200, (detail.status_code, detail.headers.get("Location"), html[:200])
assert "delete-output-csrf" not in html
if SERVER_CONFIG.CONTROL_PLANE_ENABLED:
assert f'data-archive-result-ids="{result.id}"' in html, html[-1000:]
assert 'data-delete-handoff="1"' in html, html[-1000:]
else:
assert f'data-archive-result-ids="{result.id}"' not in html
rejected = client.post(
"/admin/core/archiveresult/",
data={"action": "delete_selected", "post": "yes", "_selected_action": str(result.id)},
HTTP_HOST=snapshot_host,
)
assert rejected.status_code == 403, (rejected.status_code, rejected.headers.get("Location"), response_body(rejected)[:200])
assert ArchiveResult.objects.filter(pk=result.pk).exists()
result.delete()
print("OK")
""",
mode=mode,
)
def test_cross_domain_admin_hint_only_marks_superusers(self) -> None:
self._run(
"""
User = get_user_model()
staff = User.objects.create_user(username="staff-hint-test", password="testpassword", is_staff=True)
client = Client()
client.force_login(staff)
response = client.get("/admin/", HTTP_HOST=get_admin_host())
assert response.status_code == 200
assert client.cookies.get(ADMIN_LOGIN_HINT_COOKIE) is None or client.cookies[ADMIN_LOGIN_HINT_COOKIE].value != "1"
client.force_login(ensure_admin_user())
response = client.get("/admin/", HTTP_HOST=get_admin_host())
assert response.status_code == 200
assert client.cookies[ADMIN_LOGIN_HINT_COOKIE].value == "1"
print("OK")
""",
mode="safe-subdomains-fullreplay",
)
def test_routes_util_and_web_public_redirect(self) -> None:
self._run(
"""