fix: SSRF-haertung (IP-pinning) + blank-page-schutz aus security-review

- scan & releases/from-url: verbindung wird an die bereits validierte IP
  gepinnt (node:http/https lookup-option), statt fetch erneut aufloesen zu
  lassen. Schliesst DNS-rebinding/TOCTOU, mit dem ein lizenzierter Kunde
  ueber die DNS seiner eigenen Domain interne Dienste/Cloud-Metadaten
  erreichen koennte.
- releases/from-url folgt redirects jetzt manuell und validiert jeden hop
  (protokoll + private-IP-guard); gitea-token nur an den ausgangs-host.
- pinnedRequest: settle-guard + body-cap loesen das promise auch bei
  ueberlangen antworten (kein haengen).
- autodetect process(): kein blank-page mehr bei PCRE-fehler (fallback auf
  original-HTML statt (string) null = "").
- cleanup: ungenutztes extract_iframe_src() entfernt; redundantes
  &-replace in get_src() entfernt (DOMDocument dekodiert bereits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-08 06:52:22 +02:00
parent 40f4207024
commit ca0f2f2c32
3 changed files with 113 additions and 32 deletions

View File

@@ -52,13 +52,18 @@ class CB_Autodetect {
} }
// Locate iframe blocks only. Attribute parsing happens via DOMDocument below. // Locate iframe blocks only. Attribute parsing happens via DOMDocument below.
return (string) preg_replace_callback( $out = preg_replace_callback(
'#<iframe\b[^>]*>.*?</iframe>#is', '#<iframe\b[^>]*>.*?</iframe>#is',
function ( array $m ) use ( $services ): string { function ( array $m ) use ( $services ): string {
return self::maybe_replace_iframe( $m[0], $services ); return self::maybe_replace_iframe( $m[0], $services );
}, },
$html $html
); );
// On a PCRE error (e.g. backtrack/recursion limit on a huge page),
// preg_replace_callback returns null. Never blank the page — fall back to
// the original, unmodified HTML.
return $out === null ? $html : $out;
} }
/** /**
@@ -99,11 +104,8 @@ class CB_Autodetect {
return ''; return '';
} }
$src = trim( $node->getAttribute( 'src' ) ); // getAttribute() already returns the entity-decoded value (e.g. &amp; → &),
// so no further unescaping is needed here.
// Normalise protocol-relative and HTML-entity-encoded ampersands. return trim( $node->getAttribute( 'src' ) );
$src = str_replace( '&amp;', '&', $src );
return $src;
} }
} }

View File

@@ -288,11 +288,6 @@ class CB_Renderer {
. '</div>'; . '</div>';
} }
/** Pull the src attribute out of an iframe string. */
public static function extract_iframe_src( string $html ): string {
return self::extract_iframe_attrs( $html )['src'];
}
/** /**
* Pull src + width + height out of an iframe string. * Pull src + width + height out of an iframe string.
* Height is also read from an inline style="height:NNNpx" if no attribute. * Height is also read from an inline style="height:NNNpx" if no attribute.

View File

@@ -1,4 +1,6 @@
import express from 'express'; import express from 'express';
import http from 'node:http';
import https from 'node:https';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { mkdirSync, writeFileSync, existsSync, statSync, createReadStream } from 'node:fs'; import { mkdirSync, writeFileSync, existsSync, statSync, createReadStream } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -35,6 +37,60 @@ const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
mkdirSync(RELEASES_DIR, { recursive: true }); mkdirSync(RELEASES_DIR, { recursive: true });
/**
* HTTP(S) GET pinned to a pre-resolved, already-validated IP address. The socket
* connects to `address` while SNI/Host stay the original hostname, so TLS still
* verifies against the certificate. This closes the DNS-rebinding/TOCTOU gap
* where the global fetch() would re-resolve the host (possibly to a private IP)
* AFTER our isPrivateIp() check. Does not follow redirects.
* Resolves to { status, headers, buffer }; rejects on network/timeout error.
*/
function pinnedRequest(targetUrl, address, family, { maxBytes, timeoutMs, headers = {} }) {
return new Promise((resolve, reject) => {
const u = new URL(targetUrl);
const mod = u.protocol === 'https:' ? https : http;
let settled = false;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
fn(arg);
};
const req = mod.request(
targetUrl,
{
method: 'GET',
headers,
// Force the connection to the validated IP (handles both lookup
// callback signatures: with and without options.all).
lookup: (_hostname, opts, cb) =>
opts && opts.all ? cb(null, [{ address, family }]) : cb(null, address, family),
},
(res) => {
const chunks = [];
let bytes = 0;
const result = () => ({ status: res.statusCode || 0, headers: res.headers, buffer: Buffer.concat(chunks) });
res.on('data', (c) => {
if (settled) return;
bytes += c.length;
if (bytes <= maxBytes) {
chunks.push(c);
} else {
// Cap the body: stop reading and resolve with what we have now.
// (destroy() suppresses 'end', so we must settle here ourselves.)
res.destroy();
finish(resolve, result());
}
});
res.on('end', () => finish(resolve, result()));
res.on('error', (e) => finish(reject, e));
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timed out')));
req.on('error', (e) => finish(reject, e));
req.end();
});
}
const PORT = Number(process.env.PORT || 8080); const PORT = Number(process.env.PORT || 8080);
const ADMIN_TOKEN = process.env.ADMIN_API_TOKEN || ''; const ADMIN_TOKEN = process.env.ADMIN_API_TOKEN || '';
@@ -299,9 +355,9 @@ app.post('/api/v1/scan', async (req, res) => {
// SSRF hardening: resolve the host and refuse private/link-local IPs // SSRF hardening: resolve the host and refuse private/link-local IPs
// (e.g. a public hostname pointed at 169.254.169.254 cloud metadata). // (e.g. a public hostname pointed at 169.254.169.254 cloud metadata).
const host = new URL(t).hostname; const host = new URL(t).hostname;
let address; let address, family;
try { try {
({ address } = await lookup(host)); ({ address, family } = await lookup(host));
} catch { } catch {
pages.push({ url: t, error: 'dns lookup failed', resources: [] }); pages.push({ url: t, error: 'dns lookup failed', resources: [] });
continue; continue;
@@ -311,17 +367,20 @@ app.post('/api/v1/scan', async (req, res) => {
continue; continue;
} }
const r = await fetch(t, { // Connect to the validated IP (no re-resolution); do not follow redirects.
const r = await pinnedRequest(t, address, family, {
maxBytes: MAX_SCAN_BYTES,
timeoutMs: SCAN_TIMEOUT_MS,
headers: { 'User-Agent': 'ContentBlockerScanner/1.0', Accept: 'text/html' }, headers: { 'User-Agent': 'ContentBlockerScanner/1.0', Accept: 'text/html' },
redirect: 'manual', // do not auto-follow into unvalidated hosts
signal: AbortSignal.timeout(SCAN_TIMEOUT_MS),
}); });
if (r.status >= 300 && r.status < 400) { if (r.status >= 300 && r.status < 400) {
pages.push({ url: t, error: `redirect (${r.status}) not followed`, resources: [] }); pages.push({ url: t, error: `redirect (${r.status}) not followed`, resources: [] });
continue; continue;
} }
const buf = await r.text(); pages.push({
pages.push({ url: t, resources: extractResources(buf.slice(0, MAX_SCAN_BYTES), t) }); url: t,
resources: extractResources(r.buffer.toString('utf8').slice(0, MAX_SCAN_BYTES), t),
});
} catch (e) { } catch (e) {
pages.push({ url: t, error: String(e?.message || e), resources: [] }); pages.push({ url: t, error: String(e?.message || e), resources: [] });
} }
@@ -475,21 +534,46 @@ app.post('/api/v1/releases/from-url', adminOnly, async (req, res) => {
return fail(res, 400, 'zip_url not allowed (must start with GITEA_BASE_URL)'); return fail(res, 400, 'zip_url not allowed (must start with GITEA_BASE_URL)');
} }
// SSRF guard: refuse private/loopback targets. // Fetch the ZIP, following redirects manually so EACH hop is re-validated
// (protocol + DNS → private-IP guard + IP pinning). The global fetch with
// redirect:'follow' would only check the first URL and could be bounced into
// an internal target.
let buf;
try { try {
const { address } = await lookup(url.hostname); const initialHost = url.hostname;
if (isPrivateIp(address)) return fail(res, 400, 'zip_url resolves to a private address'); let current = zipUrl;
for (let hop = 0; ; hop++) {
if (hop > 5) return fail(res, 502, 'too many redirects for zip_url');
const u = new URL(current);
if (!/^https?:$/.test(u.protocol)) return fail(res, 400, 'zip_url redirect to non-http(s)');
let address, family;
try {
({ address, family } = await lookup(u.hostname));
} catch { } catch {
return fail(res, 400, 'dns lookup failed for zip_url'); return fail(res, 400, 'dns lookup failed for zip_url');
} }
if (isPrivateIp(address)) return fail(res, 400, 'zip_url resolves to a private address');
let buf;
try {
const headers = { 'User-Agent': 'ContentBlockerReleaseFetcher/1.0' }; const headers = { 'User-Agent': 'ContentBlockerReleaseFetcher/1.0' };
if (GITEA_TOKEN) headers.Authorization = 'token ' + GITEA_TOKEN; // Only attach the Gitea token to the original host — never leak it to a
const r = await fetch(zipUrl, { headers, redirect: 'follow', signal: AbortSignal.timeout(30000) }); // redirect target.
if (!r.ok) return fail(res, 502, 'fetch failed: HTTP ' + r.status); if (GITEA_TOKEN && u.hostname === initialHost) headers.Authorization = 'token ' + GITEA_TOKEN;
buf = Buffer.from(await r.arrayBuffer());
const r = await pinnedRequest(current, address, family, {
maxBytes: MAX_ZIP_BYTES,
timeoutMs: 30000,
headers,
});
if (r.status >= 300 && r.status < 400 && r.headers.location) {
current = new URL(r.headers.location, current).href;
continue;
}
if (r.status < 200 || r.status >= 300) return fail(res, 502, 'fetch failed: HTTP ' + r.status);
buf = r.buffer;
break;
}
} catch (e) { } catch (e) {
return fail(res, 502, 'fetch error: ' + String(e?.message || e)); return fail(res, 502, 'fetch error: ' + String(e?.message || e));
} }