← 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("&", "&")), 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)