openreader/src/lib/server/documents/web-loader.ts
Richard R 08b8b06f27 refactor(documents): replace custom SSRF checks with AntiSSRF library
Switch SSRF protection in web-loader to use @microsoft/antissrf, removing
custom IP range logic. This centralizes and future-proofs SSRF mitigation by
leveraging maintained policy sets and DNS-aware enforcement. Error handling in
the import-url API route is updated to recognize new policy error messages.
Dependency added to package.json.
2026-06-10 13:29:18 -06:00

243 lines
7.9 KiB
TypeScript

import http from 'http';
import https from 'https';
import { parseHTML } from 'linkedom';
import { Readability } from '@mozilla/readability';
import TurndownService from 'turndown';
import { AntiSSRFPolicy, PolicyConfigOptions } from '@microsoft/antissrf';
const MAX_BYTES = 2 * 1024 * 1024;
const TIMEOUT_MS = 6000;
const MAX_REDIRECTS = 5;
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* SSRF protection is delegated to Microsoft's AntiSSRF library. The policy's
* agents inject a connection-time DNS lookup that resolves the host and rejects
* any request whose resolved address falls in an internal/special-purpose range
* (loopback, private, link-local, CGNAT, cloud metadata, multicast, …). Because
* the check happens on the address the socket actually connects to, it is
* inherently safe against DNS rebinding, and it re-runs on every redirect hop
* (each hop is a fresh request through the agent).
*
* `ExternalOnlyLatest` blocks the `recommendedLatest` range set and stays
* up to date without code changes. We allow plain HTTP because article URLs are
* not always HTTPS; remove `allowPlainTextHttp` to require HTTPS.
*/
const ssrfPolicy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest);
ssrfPolicy.allowPlainTextHttp = true;
const httpAgent = ssrfPolicy.getHttpAgent();
const httpsAgent = ssrfPolicy.getHttpsAgent();
/**
* Performs a single GET request through the AntiSSRF agent. Resolves with either
* the response body or a redirect location to follow. Enforces a size limit and
* a hard timeout (resource limits, independent of the SSRF policy).
*/
function requestOnce(
url: URL,
maxBytes: number,
timeoutMs: number
): Promise<{ body?: string; redirectLocation?: string }> {
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const agent = isHttps ? httpsAgent : httpAgent;
const port = url.port ? Number(url.port) : isHttps ? 443 : 80;
const options: https.RequestOptions = {
protocol: url.protocol,
hostname: url.hostname, // connect by hostname so the agent's safe lookup runs
port,
path: `${url.pathname}${url.search}`,
method: 'GET',
agent, // AntiSSRF agent enforces the SSRF policy at connection time
headers: {
'User-Agent': USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
timeout: timeoutMs,
};
return new Promise((resolve, reject) => {
let settled = false;
const ok = (v: { body?: string; redirectLocation?: string }) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
resolve(v);
};
const fail = (e: Error) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
reject(e);
};
const req = lib.request(options, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume(); // drain so the socket can be reused/closed
ok({ redirectLocation: res.headers.location });
return;
}
if (status < 200 || status >= 300) {
res.resume();
fail(new Error(`Failed to fetch webpage (status ${status})`));
return;
}
const contentType = (res.headers['content-type'] || '').toString();
const isHtml =
contentType.includes('text/html') ||
contentType.includes('application/xhtml+xml');
const isText = contentType.includes('text/plain');
if (!isHtml && !isText) {
res.resume();
fail(
new Error('Unsupported content type. Only web pages or text files are supported.')
);
return;
}
const contentLength = res.headers['content-length'];
if (contentLength && parseInt(contentLength.toString(), 10) > maxBytes) {
res.resume();
fail(new Error('Web page size exceeds the maximum allowed limit of 2MB.'));
return;
}
// Stream and enforce the size limit dynamically (header may be absent/spoofed).
let received = 0;
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => {
received += chunk.length;
if (received > maxBytes) {
req.destroy();
fail(new Error('Web page size exceeds the maximum allowed limit of 2MB.'));
return;
}
chunks.push(chunk);
});
res.on('end', () => ok({ body: Buffer.concat(chunks).toString('utf-8') }));
res.on('error', fail);
});
// Hard deadline covering the whole request (connect + transfer), in addition
// to the socket idle timeout, to bound slow-read/slowloris responses.
const hardTimer = setTimeout(() => {
req.destroy(new Error('Request timed out'));
}, timeoutMs);
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
req.on('error', fail);
req.end();
});
}
/**
* Validates and fetches a URL with a size limit (default 2MB) and connection
* timeout (default 6s). Redirects are followed manually so each hop is re-checked
* by the AntiSSRF agent and counted against the redirect cap. Returns the body
* and the final (post-redirect) URL.
*/
async function fetchWithLimit(
urlStr: string,
maxBytes: number = MAX_BYTES,
timeoutMs: number = TIMEOUT_MS
): Promise<{ html: string; finalUrl: string }> {
let currentUrl = urlStr;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
let url: URL;
try {
url = new URL(currentUrl);
} catch {
throw new Error('Invalid URL format');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only HTTP and HTTPS protocols are supported');
}
const result = await requestOnce(url, maxBytes, timeoutMs);
if (result.redirectLocation) {
currentUrl = new URL(result.redirectLocation, url).href;
continue;
}
return { html: result.body ?? '', finalUrl: currentUrl };
}
throw new Error('Too many redirects while fetching the webpage.');
}
/**
* Connects to, extracts readable content from, and converts a web page to Markdown.
*/
export async function fetchAndParseUrl(
urlStr: string
): Promise<{ title: string; content: string }> {
// 1. Fetch HTML content with limit. The AntiSSRF agent validates the
// destination (and every redirect hop) at connection time.
const { html, finalUrl } = await fetchWithLimit(urlStr);
// 2. Parse HTML string to a virtual DOM document
const { document } = parseHTML(html);
// Resolve relative URLs against the final (post-redirect) location so links
// and images point at the page we actually fetched.
const resolveUrl = (relativeUrl: string) => {
try {
return new URL(relativeUrl, finalUrl).href;
} catch {
return relativeUrl;
}
};
const images = document.querySelectorAll('img');
for (const img of images) {
const src = img.getAttribute('src');
if (src) {
img.setAttribute('src', resolveUrl(src));
}
}
const links = document.querySelectorAll('a');
for (const link of links) {
const href = link.getAttribute('href');
if (href) {
link.setAttribute('href', resolveUrl(href));
}
}
// 3. Run Readability to extract core article text
const reader = new Readability(document as unknown as Document);
const article = reader.parse();
if (!article || !article.content) {
throw new Error('Could not extract meaningful article content from this webpage.');
}
// 4. Convert clean HTML content to Markdown
const turndownService = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
});
const markdown = turndownService.turndown(article.content);
if (!markdown.trim()) {
throw new Error('Extracted content is empty.');
}
return {
title: article.title?.trim() || 'Web Import',
content: markdown,
};
}