diff --git a/.changeset/prd-7495-headfirst-link-preview.md b/.changeset/prd-7495-headfirst-link-preview.md
new file mode 100644
index 000000000..86c8a0423
--- /dev/null
+++ b/.changeset/prd-7495-headfirst-link-preview.md
@@ -0,0 +1,5 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+Link previews now load for content-heavy pages like GitHub and Wikipedia: the fetcher streams to the end of
instead of rejecting once the page body exceeds the 512 KB cap.
diff --git a/packages/server/src/link-preview/guarded-fetch.test.ts b/packages/server/src/link-preview/guarded-fetch.test.ts
index aac37542a..4af60d8dc 100644
--- a/packages/server/src/link-preview/guarded-fetch.test.ts
+++ b/packages/server/src/link-preview/guarded-fetch.test.ts
@@ -1,8 +1,9 @@
-import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { createServer, type Server } from 'node:http';
import { gzipSync } from 'node:zlib';
+import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { listenOnLoopback } from '../loopback-rig-test-helpers.ts';
import {
+ createHeadEndScanner,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_REDIRECTS,
DEFAULT_TIMEOUT_MS,
@@ -99,6 +100,78 @@ describe('guardedFetch admission (real classifier, no network reached)', () => {
});
});
+describe('createHeadEndScanner (chunk-boundary-safe head-end detection)', () => {
+ const bytes = (text: string) => new Uint8Array(Buffer.from(text, 'latin1'));
+
+ test('finds within a single chunk and reports the offset past it', () => {
+ const scan = createHeadEndScanner();
+ const html = 'tx';
+ expect(scan(bytes(html))).toBe(html.indexOf('') + ''.length);
+ });
+
+ test.each([
+ 1, 2, 3, 4, 5, 6,
+ ])('finds split across a chunk boundary after %i marker byte(s)', (splitAt) => {
+ const scan = createHeadEndScanner();
+ const marker = '';
+ expect(scan(bytes(`t${marker.slice(0, splitAt)}`))).toBe(-1);
+ const rest = `${marker.slice(splitAt)}tail`;
+ expect(scan(bytes(rest))).toBe(marker.length - splitAt);
+ });
+
+ test('finds an opening when no precedes it', () => {
+ const scan = createHeadEndScanner();
+ const html = 'tx';
+ expect(scan(bytes(html))).toBe(html.indexOf(' {
+ const scan = createHeadEndScanner();
+ const html = 'x';
+ expect(scan(bytes(html))).toBe(html.indexOf(' {
+ const scan = createHeadEndScanner();
+ expect(scan(bytes('ttail'))).toBe(3);
+ });
+
+ test('holds a chunk-final bare {
+ const scan = createHeadEndScanner();
+ expect(scan(bytes('x'))).toBe(1);
+ });
+
+ test('is ASCII case-insensitive (, )', () => {
+ const upperHead = createHeadEndScanner();
+ expect(upperHead(bytes('T'))).toBe(
+ 'T'.length,
+ );
+ const upperBody = createHeadEndScanner();
+ expect(upperBody(bytes('x'))).toBe('', () => {
+ const scan = createHeadEndScanner();
+ const html = 'then';
+ expect(scan(bytes(html))).toBe(html.indexOf(', even across chunks', () => {
+ const scan = createHeadEndScanner();
+ expect(scan(bytes('content'))).toBe(-1);
+ });
+
+ test('returns -1 across many marker-free chunks', () => {
+ const scan = createHeadEndScanner();
+ for (let i = 0; i < 20; i++) {
+ expect(scan(bytes('plain markup with heads and bodies spelled out
'))).toBe(-1);
+ }
+ });
+});
+
describe('guardedFetch against a loopback rig (real socket)', () => {
let server: Server;
let port: number;
@@ -111,6 +184,16 @@ describe('guardedFetch against a loopback rig (real socket)', () => {
referer?: string;
} | null = null;
+ const HUGE_PAGE_HEAD =
+ 'Huge Page';
+ let hugeStream = { closedEarly: false, finishedAll: false };
+ // Resolves when the huge-streaming socket closes, so the test waits on that
+ // event instead of polling a flag. Reassigned per request, with the resolver
+ // captured in that request's own close handler, so a late close from an
+ // earlier run resolves its own promise rather than the current run's (keeps
+ // the rig correct if this suite ever gains test.retry or test.concurrent).
+ let hugeClosed: Promise = Promise.resolve();
+
// The rig binds loopback; a public hostname resolves to it via the injected
// resolver, and the guard is told to treat that one loopback address as
// public so post-admission behavior can be exercised over a real socket.
@@ -193,6 +276,96 @@ describe('guardedFetch against a loopback rig (real socket)', () => {
res.end('{"not":"html"}');
return;
}
+ if (path === '/huge-streaming') {
+ // GitHub-shaped page: the head arrives in the first write, then a body
+ // far past the byte cap keeps streaming until the client hangs up.
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.write(HUGE_PAGE_HEAD);
+ const observed = { closedEarly: false, finishedAll: false };
+ hugeStream = observed;
+ const closed = Promise.withResolvers();
+ hugeClosed = closed.promise;
+ const filler = Buffer.alloc(64 * 1024, 0x78);
+ let written = 0;
+ const timer = setInterval(() => {
+ if (res.destroyed || res.writableEnded) return;
+ if (written >= 8 * 1024 * 1024) {
+ observed.finishedAll = true;
+ clearInterval(timer);
+ res.end();
+ return;
+ }
+ written += filler.byteLength;
+ res.write(filler);
+ }, 1);
+ res.on('close', () => {
+ clearInterval(timer);
+ if (!res.writableFinished) observed.closedEarly = true;
+ closed.resolve();
+ });
+ return;
+ }
+ if (path === '/marker-in-head-script') {
+ // A spelled inside a head-level ` +
+ `RealTitle${'z'.repeat(1024)}`,
+ );
+ return;
+ }
+ if (path === '/head-end-past-cap-multichunk') {
+ // A non-marker preamble in one write, then the head-end in a later
+ // write. Only counting the preamble (`received`) pushes the total past
+ // the cap, so the guard must add it to the in-chunk offset.
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.write('a'.repeat(80));
+ setTimeout(() => res.end(`${'b'.repeat(37)}tail`), 20);
+ return;
+ }
+ if (path === '/head-split-across-writes') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.write('Split res.end('ad>after the boundary'), 20);
+ return;
+ }
+ if (path === '/body-open-no-head-close') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`NoClose${'y'.repeat(64 * 1024)}`);
+ return;
+ }
+ if (path === '/upper-head') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`Caps${'z'.repeat(64 * 1024)}`);
+ return;
+ }
+ if (path === '/marker-after-cap') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`${'x'.repeat(2048)}`);
+ return;
+ }
+ if (path === '/small-no-markers') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end('Wholeevery byte returned
');
+ return;
+ }
+ if (path === '/json-with-marker-bytes') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end('{"snippet":"","trailer":"kept"}');
+ return;
+ }
+ if (path === '/gzip-huge-head-first') {
+ // Tiny on the wire; decompressed far past the cap used by the test —
+ // but the head ends early, so the scan (which runs on DECOMPRESSED
+ // bytes) must admit it where the old whole-body read rejected it.
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Encoding': 'gzip' });
+ res.end(
+ gzipSync(`Zipped${'w'.repeat(200 * 1024)}`),
+ );
+ return;
+ }
if (path === '/slow') {
const timer = setTimeout(() => {
if (!res.writableEnded) {
@@ -335,4 +508,124 @@ describe('guardedFetch against a loopback rig (real socket)', () => {
});
expect(result).toEqual({ ok: false, reason: 'timeout' });
});
+
+ test('succeeds on a huge streaming page whose head arrives first, cancelling the rest', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/huge-streaming`, withRig);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ // The body is the exact head prefix — nothing past head-end is buffered,
+ // regardless of how the runtime coalesced the filler into chunks.
+ expect(new TextDecoder().decode(result.body)).toBe(HUGE_PAGE_HEAD);
+ expect(result.body.byteLength).toBeLessThanOrEqual(DEFAULT_MAX_BYTES);
+ // The reader hung up on the still-streaming remainder rather than consuming
+ // the multi-MB body: wait for the socket-close event, failing loudly if it
+ // never arrives instead of polling a flag on a fixed interval.
+ let timer: ReturnType | undefined;
+ const timeout = new Promise<'timeout'>((resolve) => {
+ timer = setTimeout(() => resolve('timeout'), 2000);
+ });
+ const outcome = await Promise.race([hugeClosed.then(() => 'closed' as const), timeout]);
+ clearTimeout(timer);
+ expect(outcome).toBe('closed');
+ expect(hugeStream.closedEarly).toBe(true);
+ expect(hugeStream.finishedAll).toBe(false);
+ });
+
+ test('keeps the real head when a is spelled inside a head-level RealTitle",
+ );
+ });
+
+ test('detects a split across two response writes', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/head-split-across-writes`, {
+ ...withRig,
+ maxBytes: 4 * 1024,
+ });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(new TextDecoder().decode(result.body)).toBe('Split');
+ });
+
+ test('terminates at an opening {
+ const result = await guardedFetch(`http://rig.example:${port}/body-open-no-head-close`, {
+ ...withRig,
+ maxBytes: 4 * 1024,
+ });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ // 64KB of body filler against a 4KB cap: only head-end termination admits it.
+ expect(new TextDecoder().decode(result.body)).toBe('NoClose)', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/upper-head`, {
+ ...withRig,
+ maxBytes: 4 * 1024,
+ });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(new TextDecoder().decode(result.body)).toBe('Caps');
+ });
+
+ test('still rejects as oversized when the cap is reached before head-end', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/marker-after-cap`, {
+ ...withRig,
+ maxBytes: 1024,
+ });
+ expect(result).toEqual({ ok: false, reason: 'oversized' });
+ });
+
+ test('rejects as oversized when head-end clears the cap only after earlier chunks count', async () => {
+ // 80-byte preamble in chunk one, then at offset 44 of chunk two:
+ // oversized only if the guard adds the 80 already received to the in-chunk
+ // offset (80 + 44 = 124 > 100). A check that dropped `received` would admit
+ // an over-cap body across writes.
+ const result = await guardedFetch(`http://rig.example:${port}/head-end-past-cap-multichunk`, {
+ ...withRig,
+ maxBytes: 100,
+ });
+ expect(result).toEqual({ ok: false, reason: 'oversized' });
+ });
+
+ test('returns an under-cap page with no head markers whole, as before', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/small-no-markers`, withRig);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(new TextDecoder().decode(result.body)).toBe(
+ 'Wholeevery byte returned
',
+ );
+ });
+
+ test('reads non-HTML content in full even when it contains marker-shaped bytes', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/json-with-marker-bytes`, {
+ ...withRig,
+ allowContentType: (mimeType) => mimeType === 'application/json',
+ });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(new TextDecoder().decode(result.body)).toBe(
+ '{"snippet":"","trailer":"kept"}',
+ );
+ });
+
+ test('applies the head-end scan to DECOMPRESSED bytes (gzip page far past the cap)', async () => {
+ const result = await guardedFetch(`http://rig.example:${port}/gzip-huge-head-first`, {
+ ...withRig,
+ maxBytes: 8 * 1024,
+ });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(new TextDecoder().decode(result.body)).toBe('Zipped');
+ });
});
diff --git a/packages/server/src/link-preview/guarded-fetch.ts b/packages/server/src/link-preview/guarded-fetch.ts
index 313064e9d..4fc569284 100644
--- a/packages/server/src/link-preview/guarded-fetch.ts
+++ b/packages/server/src/link-preview/guarded-fetch.ts
@@ -30,7 +30,14 @@
* re-run the full scheme+resolve+validate admission on each hop's target,
* bounded to a small hop count. Response reads are bounded on both time
* (AbortSignal) and DECOMPRESSED size, and admit only allowlisted content
- * types. Decompression contract: the request advertises
+ * types. HTML reads additionally stop at head-end (`` close or ``'s closing `>`, or the terminator that
+ * ends a `` reports
+ * the offset just past that terminator, mid-tag), or -1 while the head has not
+ * ended. Chunks are accumulated and re-scanned by `findHeadEndOffset` (the same
+ * context-aware walk the metadata extractor uses), so markup-shaped bytes inside
+ * a head-level script, comment, quoted attribute value, or `` are skipped
+ * rather than mistaken for the end, and a boundary split across chunks is
+ * detected once its bytes arrive. The accumulated view is latin1 (1:1 with
+ * bytes; the markup tokens are ASCII), so the offset it yields is a true byte
+ * offset.
+ *
+ * Re-scanning the whole accumulation per chunk is O(n^2) in the accumulated
+ * size; the fetch layer's byte cap (`DEFAULT_MAX_BYTES`) bounds that
+ * accumulation, so raising the cap raises this scan's worst-case cost
+ * quadratically.
+ */
+export function createHeadEndScanner(): (chunk: Uint8Array) => number {
+ let html = '';
+ return (chunk) => {
+ const consumed = html.length;
+ html += Buffer.from(chunk).toString('latin1');
+ const end = findHeadEndOffset(html);
+ // The previous accumulation held no boundary, so a newly found one always
+ // lands within this chunk: its offset is in (consumed, html.length].
+ return end === -1 ? -1 : end - consumed;
+ };
+}
+
function readCappedBody(
message: IncomingMessage,
maxBytes: number,
signal: AbortSignal,
+ scanHeadEnd: boolean,
): Promise<{ ok: true; body: Uint8Array } | { ok: false; reason: GuardRejectReason }> {
const encoding = (message.headers['content-encoding'] ?? '').trim().toLowerCase();
const decompressor = makeDecompressor(encoding);
@@ -358,6 +406,7 @@ function readCappedBody(
return new Promise((settle) => {
const chunks: Buffer[] = [];
+ const findHeadEnd = scanHeadEnd ? createHeadEndScanner() : null;
let received = 0;
let settled = false;
@@ -384,6 +433,24 @@ function readCappedBody(
);
}
source.on('data', (chunk: Buffer) => {
+ if (findHeadEnd) {
+ const endInChunk = findHeadEnd(chunk);
+ if (endInChunk !== -1) {
+ // Head-end seen: the head prefix is a complete successful body.
+ // Realize the head-first contract (only a cap reached BEFORE head-end
+ // is oversized) by cap-checking just the bytes up to the marker
+ // (`received + endInChunk`), then truncating there and stopping all
+ // further reads via teardown. The whole-chunk cap check below is
+ // bypassed, so bytes past head-end never count toward the cap.
+ if (received + endInChunk > maxBytes) {
+ resolveOnce({ ok: false, reason: 'oversized' });
+ return;
+ }
+ chunks.push(chunk.subarray(0, endInChunk));
+ resolveOnce({ ok: true, body: new Uint8Array(Buffer.concat(chunks)) });
+ return;
+ }
+ }
received += chunk.byteLength;
if (received > maxBytes) {
resolveOnce({ ok: false, reason: 'oversized' });
@@ -404,9 +471,12 @@ function rejectWith(reason: GuardRejectReason): GuardedFetchFailure {
}
/**
- * Fetch a URL under the SSRF guard. Returns the bounded response body plus the
- * derived content-type and final URL on success, or a bounded reason code on
- * any violation — never a partial body.
+ * Fetch a URL under the SSRF guard. On success returns the bounded response
+ * body plus the derived content-type and final URL; on any violation, a bounded
+ * reason code. For an HTML response the body is the head prefix (everything up
+ * to ``/``) — all the metadata extractor reads — while any other
+ * admitted content (the favicon path's images) is returned whole. A failure
+ * never yields a partial body.
*/
export async function guardedFetch(
rawUrl: string,
@@ -453,7 +523,15 @@ export async function guardedFetch(
return rejectWith('non-html');
}
- const bodyResult = await readCappedBody(response, maxBytes, signal);
+ // Head-first streaming applies only to HTML pages; any other admitted
+ // content (the favicon path's images) may legitimately contain
+ // marker-shaped bytes and must be read in full.
+ const bodyResult = await readCappedBody(
+ response,
+ maxBytes,
+ signal,
+ contentType === 'text/html',
+ );
if (!bodyResult.ok) return rejectWith(bodyResult.reason);
return { ok: true, body: bodyResult.body, contentType, finalUrl: target.logicalUrl };
}
diff --git a/packages/server/src/link-preview/html-metadata.test.ts b/packages/server/src/link-preview/html-metadata.test.ts
index 6a9b135d7..b025f8bd5 100644
Binary files a/packages/server/src/link-preview/html-metadata.test.ts and b/packages/server/src/link-preview/html-metadata.test.ts differ
diff --git a/packages/server/src/link-preview/html-metadata.ts b/packages/server/src/link-preview/html-metadata.ts
index b0778e353..94782ab20 100644
--- a/packages/server/src/link-preview/html-metadata.ts
+++ b/packages/server/src/link-preview/html-metadata.ts
@@ -284,19 +284,29 @@ function findCloseTag(
return null;
}
+interface HeadScan {
+ rawTitle: string;
+ metaContent: Map;
+ faviconHref: string | undefined;
+ /**
+ * Index just past the head-end boundary (``'s closing `>`, or the
+ * terminator that ends a ``), or -1 when the head does not end within `html`.
+ */
+ headEndOffset: number;
+}
+
/**
- * Extract the preview fields from a page's head. Title prefers `og:title` over
- * ``; description prefers `og:description` over the `description` meta;
- * site name is `og:site_name`. Only content inside the head (before ``
- * or ``) counts, so a stray ``/`` in the body cannot spoof
- * the card; a document with no explicit `` (title before ``) still
- * yields its head fields. Script/style content and HTML comments are skipped
- * wholesale, so markup-shaped strings inside them cannot contribute fields.
+ * One context-aware pass over a document head that both collects the metadata
+ * fields AND reports where the head ends. `extractHtmlMetadata` (fields) and
+ * `findHeadEndOffset` (boundary) are thin views over this single walk, so the
+ * streaming fetch and the parser can never disagree on where the head stops.
*/
-export function extractHtmlMetadata(html: string): RawHtmlMetadata {
+function scanHead(html: string): HeadScan {
let rawTitle = '';
const metaContent = new Map();
let faviconHref: string | undefined;
+ let headEndOffset = -1;
try {
const lower = asciiLowerCase(html);
@@ -334,7 +344,16 @@ export function extractHtmlMetadata(html: string): RawHtmlMetadata {
if (next === 0x2f /* / */) {
const { name, end } = readTagName(lower, lt + 2);
- if (name === 'head') break;
+ if (name === 'head') {
+ // Record the boundary only once a `>` closes the tag in-buffer. A
+ // bare trailing `` for head-end.
+ if (end < len) {
+ const gt = lower.indexOf('>', end);
+ if (gt !== -1) headEndOffset = gt + 1;
+ }
+ break;
+ }
const gt = lower.indexOf('>', end);
if (gt === -1) break;
i = gt + 1;
@@ -349,7 +368,18 @@ export function extractHtmlMetadata(html: string): RawHtmlMetadata {
const { name, end: nameEnd } = readTagName(lower, lt + 1);
- if (name === 'body') break;
+ if (name === 'body') {
+ // Head-end at the opening body tag, but only once a real terminator
+ // (`>`, `/`, or whitespace) settles the name in-buffer; a bare trailing
+ // ` */ || term === 0x2f /* / */ || isWhitespaceCode(term)) {
+ headEndOffset = nameEnd + 1;
+ }
+ }
+ break;
+ }
if (name === 'title') {
const contentStart = scanAttributes(html, lower, nameEnd, null);
@@ -408,6 +438,21 @@ export function extractHtmlMetadata(html: string): RawHtmlMetadata {
// request handler.
}
+ return { rawTitle, metaContent, faviconHref, headEndOffset };
+}
+
+/**
+ * Extract the preview fields from a page's head. Title prefers `og:title` over
+ * ``; description prefers `og:description` over the `description` meta;
+ * site name is `og:site_name`. Only content inside the head (before ``
+ * or ``) counts, so a stray ``/`` in the body cannot spoof
+ * the card; a document with no explicit `` (title before ``) still
+ * yields its head fields. Script/style content and HTML comments are skipped
+ * wholesale, so markup-shaped strings inside them cannot contribute fields.
+ */
+export function extractHtmlMetadata(html: string): RawHtmlMetadata {
+ const { rawTitle, metaContent, faviconHref } = scanHead(html);
+
const title = sanitizeText(metaContent.get('og:title') ?? rawTitle, MAX_TITLE);
const description = sanitizeText(
metaContent.get('og:description') ?? metaContent.get('description') ?? '',
@@ -423,6 +468,20 @@ export function extractHtmlMetadata(html: string): RawHtmlMetadata {
return result;
}
+/**
+ * Byte offset just past the end of a document head (``'s closing `>`, or
+ * the terminator that ends a ``), found by the SAME context-aware walk the metadata
+ * extractor uses, so markup-shaped bytes inside a head-level script, comment,
+ * quoted attribute value, or `` never count as the boundary. Returns -1
+ * when the head has not ended within `html`, which lets a streaming caller keep
+ * reading; a bare trailing `