← back to Paul Conrad Archive

tests/test_image_allowlist.py

235 lines

"""IMAGE ALLOWLIST tests (replaces the old blanket no-<img> test — Steve's TK-12199 decision).

Rule: an image may appear on the private viewer ONLY as a hotlink to the holding institution's own public image, on an
exact-host allowlist; nothing is ever downloaded, proxied, cached or stored. These tests assert:
  * every <img src> in rendered HTML and every display_image in the API is on the allowlist
  * a record whose stored image lives on a NON-allowlisted host (or is an item PAGE) yields display_image = null and
    never leaks the raw URL
  * the CSP header carries img-src with exactly the allowlisted hosts
  * the detector itself goes RED if the allowlist check is bypassed (negative test, CLAUDE.md TK-11431 rule 3)
  * no image bytes exist anywhere under the repo (data/, cache, DB) and local_image stays NULL
The crawler's image-refusal rail is covered by tests/test_image_rail_content.py (still required to pass).
"""
import json
import re
import sqlite3
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from conrad import config, db
from conrad.crawlers import base
from conrad.models import CartoonRecord
from conrad.web import imagehost

LOC_THUMB = "https://tile.loc.gov/storage-services/service/pnp/acd/2a07000/2a07600/2a07662_150px.jpg"
LOC_MEDIUM = "https://tile.loc.gov/storage-services/service/pnp/acd/2a07000/2a07600/2a07662r.jpg"
HC_THUMB = ("http://5008.sydneyplus.com/HistoryColorado_ArgusNet_Final/ViewImage.aspx?template=Image&field=DerivedIma"
            "&hash=0A0B7B209A21EFFA1037D98354ACC011")
EVIL = "https://evil-image-cdn.example.com/conrad/nixon.jpg"
PAGE_NOT_IMAGE = "https://www.loc.gov/pictures/item/2016680206/"
EXPECTED_CSP = ("img-src 'self' https://tile.loc.gov https://cdn.loc.gov https://5008.sydneyplus.com "
                "https://ca-times.brightspotcdn.com https://library.syracuse.edu https://www.original-political-cartoon.com "
                "https://pophistorydig.com https://www.truthdig.com")
# web_images hosts (TK-12199 2026-09-25): real stored URLs, verified 200 image/* with our UA (no bytes kept)
WEB_OK = [
    "https://library.syracuse.edu/extsites/cartoonists/images/15.jpg",
    "https://www.truthdig.com/wp-content/uploads/2017/07/conrad_elephant_500.gif",
    "https://pophistorydig.com/wp-content/uploads/2011/05/1986-Reagan-1-260.jpg",
    "https://www.original-political-cartoon.com/media/filer_public_thumbnails/filer_public/2022/04/29/conrad2.jpg__400x400_q85.jpg",
    "https://ca-times.brightspotcdn.com/dims4/default/d3f4d61/2147483647/strip/true/crop/546x510+0+0/resize/546x510!/quality/75/?url=https%3A%2F%2Fx.jpg",
]
# robots.txt of image.invaluable.com answers 403 -> unreadable -> treated as disallowed -> never allowlisted
INVALUABLE = "https://image.invaluable.com/housePhotos/alexautographs/70/640170/H0171-L164416659.jpg"
IMG_SRC = re.compile(r"<img\b[^>]*\bsrc=\"([^\"]*)\"", re.I)
CSS_URL = re.compile(r"url\(\s*['\"]?([^'\")]+)", re.I)
MAGIC = base.IMAGE_MAGIC


def _rec(n, title, image_url=None, thumb=None, repo="Library of Congress"):
    return CartoonRecord(canonical_id=f"t:{n}", identifier=str(n), title=title, year=1973, date_start="1973-01-01",
                         date_end="1973-12-31", repository=repo, record_url=f"https://www.loc.gov/pictures/item/{n}/",
                         image_url=image_url, thumbnail_url=thumb, access_level="online_image",
                         people=["Nixon, Richard M."])


@pytest.fixture
def client(tmpdb, monkeypatch):
    monkeypatch.delenv("CONRAD_BASIC_USER", raising=False)
    monkeypatch.delenv("CONRAD_REQUIRE_AUTH", raising=False)
    db.upsert_source(tmpdb, "t", "test")
    db.save_record(tmpdb, _rec(1, "Allowed LOC cartoon", thumb=LOC_THUMB), "t")
    db.save_record(tmpdb, _rec(2, "Allowed History Colorado cartoon", thumb=HC_THUMB, repo="History Colorado"), "t")
    db.save_record(tmpdb, _rec(3, "Evil host cartoon", image_url=EVIL, thumb=EVIL), "t")
    db.save_record(tmpdb, _rec(4, "Item page not image cartoon", image_url=PAGE_NOT_IMAGE), "t")
    db.save_record(tmpdb, _rec(5, "Text only cartoon"), "t")
    tmpdb.commit()
    from conrad.web.app import create_app
    return TestClient(create_app())


def _ids(client):
    rs = client.get("/api/search?q=cartoon&limit=500").json()["results"]
    return {r["title"]: r for r in rs}


def assert_images_allowlisted(text: str, where: str):
    """The detector: every <img src>, CSS url() image and display_image URL must be on the allowlist, and neither a
    non-allowlisted stored URL nor a raw item page may ever reach the browser."""
    for src in IMG_SRC.findall(text):
        if "${" in src:  # a JS template slot, filled only from display_image (checked via the API below)
            continue
        assert imagehost.is_allowed(src.replace("&amp;", "&")), f"non-allowlisted <img src> {src!r} in {where}"
    for u in CSS_URL.findall(text):
        assert u.startswith("/") or imagehost.is_allowed(u), f"non-allowlisted css url() {u!r} in {where}"
    assert EVIL not in text, f"non-allowlisted image URL leaked in {where}"
    try:
        payload = json.loads(text)
    except ValueError:
        return
    stack = [payload]
    while stack:
        x = stack.pop()
        if isinstance(x, dict):
            if x.get("display_image"):
                assert imagehost.is_allowed(x["display_image"]["url"]), f"display_image off-allowlist in {where}"
            stack.extend(x.values())
        elif isinstance(x, list):
            stack.extend(x)


def test_display_image_only_for_allowlisted_hosts(client):
    by = _ids(client)
    assert by["Allowed LOC cartoon"]["display_image"]["url"] == LOC_MEDIUM  # 150px thumb -> LOC medium 'r' derivative
    hc = by["Allowed History Colorado cartoon"]["display_image"]["url"]
    assert hc.startswith("https://5008.sydneyplus.com/") and "ViewImage.aspx" in hc  # upgraded to https
    assert by["Evil host cartoon"]["display_image"] is None
    assert by["Item page not image cartoon"]["display_image"] is None
    assert by["Text only cartoon"]["display_image"] is None
    for t in ("Evil host cartoon", "Item page not image cartoon", "Text only cartoon"):
        d = client.get(f"/api/cartoon/{by[t]['id']}").json()
        assert d["display_image"] is None
        page = client.get(f"/cartoon/{by[t]['id']}").text
        assert not IMG_SRC.search(page), f"{t}: detail page rendered an <img>"
        assert "View at Library of Congress" in page  # the link-out survives


def test_every_rendered_image_is_allowlisted(client):
    by = _ids(client)
    paths = ["/", "/static/app.js", "/static/style.css", "/static/index.html", "/api/search?q=cartoon&limit=500"]
    paths += [f"/cartoon/{r['id']}" for r in by.values()] + [f"/api/cartoon/{r['id']}" for r in by.values()]
    for p in paths:
        r = client.get(p)
        assert r.status_code == 200, p
        assert_images_allowlisted(r.text, p)
    page = client.get(f"/cartoon/{by['Allowed LOC cartoon']['id']}").text
    srcs = IMG_SRC.findall(page)
    assert srcs == [LOC_MEDIUM]
    assert 'referrerpolicy="no-referrer"' in page and 'loading="lazy"' in page and 'alt="Allowed LOC cartoon"' in page
    assert "Image: Library of Congress" in page  # credit line


def test_has_image_filter(client):
    rs = client.get("/api/search?q=cartoon&has_image=true&limit=500").json()
    assert sorted(r["title"] for r in rs["results"]) == ["Allowed History Colorado cartoon", "Allowed LOC cartoon"]
    assert rs["total"] == 2


def test_csp_header_exact_hosts(client):
    assert imagehost.CSP == EXPECTED_CSP
    for p in ["/", "/api/search", "/static/app.js", "/robots.txt"]:
        h = client.get(p).headers
        assert h["content-security-policy"] == EXPECTED_CSP, p
        assert h["x-robots-tag"] == "noindex, nofollow"


def test_detector_goes_red_when_allowlist_bypassed(client, monkeypatch):
    """Negative test: with the host check removed, the evil record gets a display_image and the detector MUST fail."""
    def permissive(url):
        return url if url and url.startswith("http") and "/pictures/item/" not in url else None
    monkeypatch.setattr(imagehost, "display_url", permissive)
    monkeypatch.setattr(imagehost, "is_allowed", lambda u: bool(u))  # the defence-in-depth check removed too
    leaked = client.get("/api/search?q=cartoon&limit=500").text
    assert EVIL in leaked  # the fault really was injected
    monkeypatch.undo()
    with pytest.raises(AssertionError):
        assert_images_allowlisted(leaked, "bypassed /api/search")


@pytest.mark.parametrize("url", [EVIL, PAGE_NOT_IMAGE, "https://tile.loc.gov.evil.com/service/pnp/x/1_150px.jpg",
                                 "https://user:pw@tile.loc.gov/service/pnp/x/1_150px.jpg",
                                 "javascript:alert(1)", "data:image/png;base64,AAAA", "ftp://tile.loc.gov/service/pnp/a.jpg",
                                 "https://5008.sydneyplus.com/HistoryColorado_ArgusNet_Final/Portal/Portal.aspx?x=1",
                                 INVALUABLE, "https://www.truthdig.com/articles/paul-conrad-1924-2010/",
                                 "https://pophistorydig.com/topics/paul-conrad-1924-2010/",
                                 "https://library.syracuse.edu.evil.com/extsites/cartoonists/images/15.jpg"])
def test_display_url_rejects(url):
    assert imagehost.display_url(url) is None


@pytest.mark.parametrize("url", WEB_OK)
def test_web_image_hosts_allowed(url):
    d = imagehost.display_url(url)
    assert d is not None and imagehost.is_allowed(d) and d.startswith("https://" + imagehost.urlparse(url).hostname + "/")


def test_invaluable_not_allowlisted():
    assert "image.invaluable.com" not in imagehost.ALLOWED_IMAGE_HOSTS
    assert "invaluable" not in imagehost.CSP


def test_local_image_column_is_always_null(tmpdb):
    db.upsert_source(tmpdb, "x", "x")
    db.save_record(tmpdb, CartoonRecord(canonical_id="x:1", identifier="1", image_url=LOC_THUMB), "x")
    with pytest.raises(sqlite3.IntegrityError):
        tmpdb.execute("UPDATE cartoon_sources SET local_image='data/img/1.jpg'")
    assert tmpdb.execute("SELECT COUNT(*) FROM cartoon_sources WHERE local_image IS NOT NULL").fetchone()[0] == 0


SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".pytest_cache"}
IMG_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".tif", ".tiff", ".webp", ".bmp", ".jp2", ".heic", ".avif"}


def _repo_files():
    for p in config.ROOT.rglob("*"):
        if p.is_file() and not (SKIP_DIRS & set(p.relative_to(config.ROOT).parts)):
            yield p


def test_no_image_bytes_anywhere_in_repo():
    offenders = []
    for p in _repo_files():
        if p.suffix.lower() in IMG_EXTS:
            offenders.append(f"{p}: image extension")
            continue
        with open(p, "rb") as fh:
            head = fh.read(16)
        if head.startswith(MAGIC) and p.suffix.lower() not in {".pdf"}:
            offenders.append(f"{p}: image magic bytes")
        if p.suffix == ".json" and "cache" in p.parts:  # cached HTTP bodies: never an image body, never inline data
            try:
                body = json.loads(p.read_text(errors="replace")).get("body", "")
            except (ValueError, AttributeError):
                body = ""
            if isinstance(body, str) and (body[:8].encode("latin-1", "replace").startswith(MAGIC)
                                          or "data:image/" in body[:2000]):
                offenders.append(f"{p}: cached image body")
    assert offenders == [], offenders[:10]
    real = config.ROOT / "data" / "conrad.db"
    if real.exists():
        c = sqlite3.connect(f"file:{real}?mode=ro", uri=True)
        assert c.execute("SELECT COUNT(*) FROM cartoon_sources WHERE local_image IS NOT NULL").fetchone()[0] == 0
        for (t,) in c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"):
            cols = [r[1] for r in c.execute(f"PRAGMA table_info('{t}')")]
            for col in cols:
                n = c.execute(f"SELECT COUNT(*) FROM \"{t}\" WHERE typeof(\"{col}\")='blob'").fetchone()[0]
                assert n == 0, f"BLOB stored in {t}.{col}"


def test_crawler_image_rail_intact():
    """The crawler must still refuse image URLs before any I/O (the hotlink decision does not loosen it)."""
    for u in (LOC_THUMB, LOC_MEDIUM, EVIL):
        with pytest.raises(base.Blocked, match="copyright rail"):
            base.Http(use_cache=False).get(u, check_robots=False)