Files
thoughtsync/tests/test_unfurl.py
T
bvandeusenandClaude Opus 4.8 69bf04e948
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 33s
M6 1901: URL capture with link-preview unfurl (SSRF-hardened)
Paste a link → fetch its OpenGraph/meta preview (title, description, image,
site) and show a rich card. User-triggered + persisted (never auto-fetches;
cached so it never re-fetches). Opt-in via a new admin setting
enable_url_unfurl (default on, rule 26).

Security (the whole point of this task): a new dependency-free unfurl.py
does the fetch with layered SSRF defenses — http/https only; resolve the
host and reject EVERY non-public address (private/loopback/link-local/
reserved/multicast/unspecified — blocks 169.254.169.254 etc.); connect to
the vetted IP with SNI so DNS-rebinding can't slip through; ≤3 redirects
each re-validated; 5s timeout; 512 KB cap; text/html only; blocking IO in a
worker thread. No server-side image fetch — the og:image URL is loaded by
the browser.

- note_link_previews table (migration 0020), one per (note, url); serialized
  inline on notes (+ rides the sync pull feed read-only).
- POST /api/notes/<id>/unfurl {url} (owner-scoped, setting-gated, 502 on
  fetch failure); DELETE /api/notes/<id>/previews/<id>.
- enable_url_unfurl exposed in public config so the UI hides the affordance
  when disabled.

Frontend: LinkPreview.vue card; editor detects URLs in the body and offers a
"Preview <domain>" chip per un-previewed link (ensureDraft first), renders
preview cards with remove; card shows previews read-only. New link icon;
notes-store unfurl()/deletePreview().

Tests (DB-free): is_public_ip range blocking, validate_url scheme/parts,
extract_preview (OG + <title> fallback + relative-image resolve), endpoint
auth-guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 08:05:24 -04:00

75 lines
2.9 KiB
Python

import ipaddress
import pytest
from thoughtsync.app import create_app
from thoughtsync.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url
@pytest.fixture
def app():
return create_app()
def test_is_public_ip_blocks_internal_ranges():
assert is_public_ip(ipaddress.ip_address("8.8.8.8"))
assert is_public_ip(ipaddress.ip_address("2606:4700:4700::1111"))
# everything internal / special is rejected (the SSRF core)
assert not is_public_ip(ipaddress.ip_address("10.0.0.1")) # private
assert not is_public_ip(ipaddress.ip_address("192.168.1.1")) # private
assert not is_public_ip(ipaddress.ip_address("127.0.0.1")) # loopback
assert not is_public_ip(ipaddress.ip_address("169.254.169.254")) # link-local (cloud metadata)
assert not is_public_ip(ipaddress.ip_address("0.0.0.0")) # unspecified
assert not is_public_ip(ipaddress.ip_address("::1")) # loopback v6
assert not is_public_ip(ipaddress.ip_address("fc00::1")) # unique-local v6
def test_validate_url_scheme_and_parts():
assert validate_url("https://example.com/a?b=c") == ("https", "example.com", 443, "/a?b=c")
assert validate_url("http://x.io")[3] == "/" # default path
assert validate_url("http://x.io:8080/p")[2] == 8080 # explicit port
for bad in ("file:///etc/passwd", "ftp://x", "gopher://x", "not a url", ""):
with pytest.raises(UnfurlError):
validate_url(bad)
def test_extract_preview_opengraph():
html = (
b"<html><head>"
b'<meta property="og:title" content="Hello &amp; World">'
b'<meta property="og:description" content="A page">'
b'<meta property="og:image" content="/img.png">'
b'<meta property="og:site_name" content="Example">'
b"</head></html>"
)
p = extract_preview("https://example.com/page", html)
assert p["title"] == "Hello & World" # entities decoded
assert p["description"] == "A page"
assert p["image_url"] == "https://example.com/img.png" # relative resolved to absolute
assert p["site_name"] == "Example"
def test_extract_preview_title_fallback_and_host_defaults():
html = b"<html><head><title> Just a Title </title></head></html>"
p = extract_preview("https://example.com", html)
assert p["title"] == "Just a Title" # whitespace collapsed
assert p["description"] is None
assert p["image_url"] is None
assert p["site_name"] == "example.com" # falls back to host
async def test_unfurl_requires_auth(app):
client = app.test_client()
resp = await client.post(
"/api/notes/00000000-0000-0000-0000-000000000000/unfurl", json={"url": "https://x.com"}
)
assert resp.status_code == 401
async def test_delete_preview_requires_auth(app):
client = app.test_client()
resp = await client.delete(
"/api/notes/00000000-0000-0000-0000-000000000000/previews/00000000-0000-0000-0000-000000000001"
)
assert resp.status_code == 401