diff --git a/Dockerfile b/Dockerfile index 9c5f133a..46a9e3be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,7 +66,7 @@ ENV TMP_DIR=/tmp/archivebox \ ENV HOME=/home/archivebox \ XDG_CONFIG_HOME=/home/archivebox/.config \ - XDG_CACHE_HOME=/home/archivebox/.cache \ + XDG_CACHE_HOME=/opt/archivebox/lib/cache \ ABXPKG_INSTALL_TIMEOUT=600 \ ABXPKG_POSTINSTALL_SCRIPTS=True \ ABXPKG_MIN_RELEASE_AGE=0 \ @@ -222,9 +222,8 @@ RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." && [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_GID" ]] || groupmod -g "$DEFAULT_ARCHIVEBOX_GID" "$ARCHIVEBOX_USER" \ && (which sonic && sonic --version) | tee -a /VERSION.txt \ && install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \ - && install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" "/home/$ARCHIVEBOX_USER/.cache" \ - && install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" "/home/$ARCHIVEBOX_USER/.cache/pnpm" "/home/$ARCHIVEBOX_USER/.cache/uv" \ - && chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "/home/$ARCHIVEBOX_USER/.cache/abxbus" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \ + && install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \ + && chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \ && openssl rand -hex 16 > /etc/machine-id \ && echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \ && echo -e "TMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt @@ -239,7 +238,7 @@ RUN echo "[+] Initializing image collection..." \ "$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \ "$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \ "$CONFIG_DIR" "$CONFIG_DIR"/config.env "$CONFIG_DIR"/derived.env \ - "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "/home/$ARCHIVEBOX_USER/.cache" \ + "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \ 2>/dev/null || true) \ && find "$TMP_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + @@ -256,9 +255,6 @@ RUN "$LIB_DIR/playwright/bin/chromium" --version | tee -a /VERSION.txt \ && setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$CONFIG_DIR" \ && setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$LIB_DIR" \ && setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox version 2>&1 | tee -a /VERSION.txt \ - && chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER/.cache" \ - && setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \ - && setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "/home/$ARCHIVEBOX_USER/.cache/uv" \ && setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox install \ && rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/* diff --git a/archivebox/config/django.py b/archivebox/config/django.py index 12354e0d..538a9e7d 100644 --- a/archivebox/config/django.py +++ b/archivebox/config/django.py @@ -41,6 +41,12 @@ def setup_django(check_db=False, in_memory_db=False) -> None: # TODO: figure out why CLI entrypoints with init_pending are running this twice sometimes return + # SQLite creates index.sqlite3 during django.setup()/migrate. Apply the + # ArchiveBox file-mode policy before any DB connection can create the file, + # otherwise a permissive parent umask can expose a just-created DB until a + # later chmod runs. + os.umask(0o777 - (int(CONFIG.OUTPUT_PERMISSIONS, base=8) | 0o111)) + # Third-party patches are only needed once Django/apps are about to load. # Keeping them out of archivebox.__init__ avoids paying Django/Daphne setup # cost for cheap CLI startup paths like `archivebox --help`. diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py index 08e4e5fb..a1436942 100644 --- a/archivebox/core/admin_archiveresults.py +++ b/archivebox/core/admin_archiveresults.py @@ -157,10 +157,12 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None): ''' # Truncate output for display - full_output = result.output_str_for_display() or "-" - output_display = full_output[:60] - if len(full_output) > 60: - output_display += "..." + full_output_raw = result.output_str_for_display() or "-" + output_display_raw = full_output_raw[:60] + if len(full_output_raw) > 60: + output_display_raw += "..." + full_output = html.escape(full_output_raw) + output_display = html.escape(output_display_raw) display_cmd = build_abx_dl_display_command(result) replay_cmd = build_abx_dl_replay_command(result, config=config) diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 5d0b31fb..fc42c77a 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -52,6 +52,7 @@ from archivebox.misc.util import ( base_url, filter_queryset_by_uuid_substring, htmlencode, + sanitize_html_text, ts_to_date_str, urldecode, validate_url, @@ -1491,7 +1492,7 @@ class AddView(UserPassesTestMixin, FormView): config=config, ) if notes: - crawl.safe_update({"notes": notes}, refresh=False) + crawl.safe_update({"notes": sanitize_html_text(notes)}, refresh=False) if permissions and crawl.config.get("PERMISSIONS") != permissions: next_config = {**crawl.config, "PERMISSIONS": permissions} crawl.safe_update({"config": next_config}, refresh=True) diff --git a/archivebox/core/widgets.py b/archivebox/core/widgets.py index c4c60047..61b97432 100644 --- a/archivebox/core/widgets.py +++ b/archivebox/core/widgets.py @@ -40,6 +40,16 @@ class TagEditorWidget(forms.Widget): normalized = f"t_{normalized}" return normalized + def _json_for_inline_script(self, value): + """Serialize JSON so it cannot close the surrounding inline " + + response = client.post( + reverse("add"), + data={ + "url": "https://example.com/notes-xss", + "tag": "", + "depth": "0", + "max_urls": "1", + "crawl_max_size": "0", + "crawl_timeout": "0", + "timeout": "", + "snapshot_max_size": "0", + "delete_after": "0", + "crawl_max_concurrent_snapshots": "1", + "url_filters_allowlist": "", + "url_filters_denylist": "", + "notes": malicious_notes, + "schedule": "", + "persona": "Default", + "permissions": "public", + "start_paused": "", + "config": "{}", + }, + HTTP_HOST=ADMIN_HOST, + ) + + assert response.status_code == 302, response.context["form"].errors if response.context else response.content.decode() + crawl = Crawl.objects.order_by("-created_at").first() + assert crawl is not None + assert crawl.notes == "window.__archivebox_add_notes_xss__=1" + assert "" not in crawl.notes + + def test_add_view_unchecked_only_new_sets_crawl_override(client, admin_user, monkeypatch): monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") client.force_login(admin_user) diff --git a/archivebox/tests/test_ui_admin_snapshot.py b/archivebox/tests/test_ui_admin_snapshot.py index e8bb0619..b63901bc 100644 --- a/archivebox/tests/test_ui_admin_snapshot.py +++ b/archivebox/tests/test_ui_admin_snapshot.py @@ -8,6 +8,7 @@ from types import SimpleNamespace import pytest from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.core.paginator import UnorderedObjectListWarning +from django.db import connection from django.test import RequestFactory from django.urls import reverse from django.utils import timezone @@ -32,6 +33,49 @@ def test_snapshot_changelist_uses_stable_ordering_without_unordered_paginator_wa assert b"Searching matching snapshots..." in response.content +def test_snapshot_admin_tag_editor_escapes_tag_json_script_breakout(admin_client, snapshot): + from archivebox.core.models import Tag + + tag = Tag.objects.create(name="legacy-safe-tag") + snapshot.tags.add(tag) + malicious_name = '' + with connection.cursor() as cursor: + cursor.execute( + f"UPDATE {Tag._meta.db_table} SET name = %s WHERE id = %s", + [malicious_name, str(tag.pk)], + ) + + 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 malicious_name.encode() not in body + assert b'" + tag_payload = "" + with connection.cursor() as cursor: + cursor.execute(f"UPDATE {Snapshot._meta.db_table} SET title = %s WHERE id = %s", [title_payload, str(snapshot.pk)]) + cursor.execute(f"UPDATE {Tag._meta.db_table} SET name = %s WHERE id = %s", [tag_payload, str(tag.pk)]) + + 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) + + assert public_index.status_code == 200 + assert snapshot_detail.status_code == 200 + for response in (public_index, snapshot_detail): + assert b"