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
This commit is contained in:
@@ -32,6 +32,16 @@ export interface Attachment {
|
||||
sha256?: string | null;
|
||||
}
|
||||
|
||||
// A cached OpenGraph/meta preview for a URL in the note (server-fetched, SSRF-guarded).
|
||||
export interface LinkPreview {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
site_name: string | null;
|
||||
}
|
||||
|
||||
// A past version of a note's title+body (version history).
|
||||
export interface NoteRevision {
|
||||
id: string;
|
||||
@@ -57,6 +67,7 @@ export interface Note {
|
||||
labels: NoteLabel[];
|
||||
items: ChecklistItem[];
|
||||
attachments: Attachment[];
|
||||
previews: LinkPreview[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -174,6 +185,14 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
|
||||
}
|
||||
|
||||
async function unfurl(id: string, url: string): Promise<void> {
|
||||
reconcile(await api.post<Note>(`/api/notes/${id}/unfurl`, { url }));
|
||||
}
|
||||
|
||||
async function deletePreview(id: string, previewId: string): Promise<void> {
|
||||
reconcile(await api.del<Note>(`/api/notes/${id}/previews/${previewId}`));
|
||||
}
|
||||
|
||||
async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
@@ -262,6 +281,8 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
deleteItem,
|
||||
uploadAttachment,
|
||||
deleteAttachment,
|
||||
unfurl,
|
||||
deletePreview,
|
||||
importNotes,
|
||||
fetchOne,
|
||||
createTitled,
|
||||
|
||||
Reference in New Issue
Block a user