' + esc(h.from || (i === 0 ? 'Origin' : 'Internal handoff')) + ' → ' + esc(h.by || '?') + '
' +
(h.ip ? 'IP:
' + esc(h.ip) + ' · ' : '') +
(h.proto ? 'Protocol: ' + esc(h.proto) + ' · ' : '') +
diff --git a/public/js/header-analyzer.js b/public/js/header-analyzer.js
index b2461349..7ba4295c 100644
--- a/public/js/header-analyzer.js
+++ b/public/js/header-analyzer.js
@@ -28,12 +28,15 @@ var _haSecurityHeaders = [
{ name: 'X-Frame-Options', weight: 10, critical: false, desc: 'Prevents clickjacking by controlling iframe embedding.', rec: 'Add: X-Frame-Options: DENY (or SAMEORIGIN)', weak: function(v) { return v && v.toLowerCase() === 'allowall'; } },
{ name: 'Referrer-Policy', weight: 8, critical: false, desc: 'Controls what referrer information is sent with requests.', rec: 'Add: Referrer-Policy: strict-origin-when-cross-origin', weak: function(v) { return v && (v.toLowerCase() === 'unsafe-url' || v.toLowerCase() === 'no-referrer-when-downgrade'); } },
{ name: 'Permissions-Policy', weight: 8, critical: false, desc: 'Controls which browser features the site can use (camera, microphone, geolocation).', rec: 'Add: Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()' },
- { name: 'X-XSS-Protection', weight: 5, critical: false, desc: 'Legacy XSS filter. Modern browsers use CSP instead, but still good to have.', rec: 'Add: X-XSS-Protection: 0 (or rely on CSP instead). Note: deprecated in modern browsers.' },
+ // Optional headers are shown but never lower the grade: X-XSS-Protection is retired (enabling
+ // the old filter caused leaks, so OWASP says omit it or send 0), and COEP/CORP only matter for
+ // cross-origin isolation; require-corp breaks sites that load third-party fonts, sign-in or CDNs.
+ { name: 'X-XSS-Protection', weight: 5, optional: true, critical: false, desc: 'Legacy XSS filter, removed from modern browsers. Not needed; CSP replaces it. If sent, it should be 0.', rec: 'Leave it out, or send X-XSS-Protection: 0. A value of 1 turns on the old filter, which could itself leak data.', weak: function(v) { return v && !/^\s*0\s*$/.test(v); } },
{ name: 'Cross-Origin-Opener-Policy', weight: 7, critical: false, desc: 'Isolates browsing context to prevent Spectre-style attacks.', rec: 'Add: Cross-Origin-Opener-Policy: same-origin' },
- { name: 'Cross-Origin-Resource-Policy', weight: 7, critical: false, desc: 'Controls which origins can load this resource.', rec: 'Add: Cross-Origin-Resource-Policy: same-origin (or same-site)' },
- { name: 'Cross-Origin-Embedder-Policy', weight: 5, critical: false, desc: 'Ensures all sub-resources are loaded with CORS or CORP headers.', rec: 'Add: Cross-Origin-Embedder-Policy: require-corp' },
- { name: 'X-DNS-Prefetch-Control', weight: 3, critical: false, desc: 'Controls DNS prefetching which can leak browsing information.', rec: 'Add: X-DNS-Prefetch-Control: off' },
- { name: 'X-Permitted-Cross-Domain-Policies', weight: 2, critical: false, desc: 'Controls Adobe Flash/PDF cross-domain policy loading.', rec: 'Add: X-Permitted-Cross-Domain-Policies: none' }
+ { name: 'Cross-Origin-Resource-Policy', weight: 7, optional: true, critical: false, desc: 'Controls which origins can load this resource.', rec: 'Add: Cross-Origin-Resource-Policy: same-origin (or same-site)' },
+ { name: 'Cross-Origin-Embedder-Policy', weight: 5, optional: true, critical: false, desc: 'Ensures all sub-resources are loaded with CORS or CORP headers. Only needed for cross-origin isolation (SharedArrayBuffer, high-resolution timers).', rec: 'Only if the site needs cross-origin isolation: Cross-Origin-Embedder-Policy: require-corp (or credentialless). It blocks third-party resources that do not opt in.' },
+ { name: 'X-DNS-Prefetch-Control', weight: 3, optional: true, critical: false, desc: 'Controls DNS prefetching which can leak browsing information.', rec: 'Add: X-DNS-Prefetch-Control: off' },
+ { name: 'X-Permitted-Cross-Domain-Policies', weight: 2, optional: true, critical: false, desc: 'Controls Adobe Flash/PDF cross-domain policy loading.', rec: 'Add: X-Permitted-Cross-Domain-Policies: none' }
];
function _haGradeFromScore(score) {
@@ -104,7 +107,7 @@ async function _haAnalyze(url) {
var headers = {};
var rawHeaders = resp.headers || {};
Object.keys(rawHeaders).forEach(function(key) { headers[key.toLowerCase()] = rawHeaders[key]; });
- _haRender(resultsEl, { url: url, headers: headers, status: resp.status, statusText: resp.statusText || '', finalUrl: resp.finalUrl || url, timing: Math.round(performance.now() - start), redirects: resp.redirectChain || [], source: 'proxy' });
+ _haRender(resultsEl, { url: url, headers: headers, status: resp.status, statusText: resp.statusText || '', finalUrl: resp.finalUrl || url, timing: Math.round(performance.now() - start), redirects: resp.redirectChain || [], masked: resp.masked || [], source: 'proxy' });
} catch (e) {
resultsEl.innerHTML = _haBox('Analysis failed', e.message || 'Request failed', 'The target could not be reached through the Darknode proxy (the host may be down, private, or blocking automated requests). No results are shown — nothing is guessed.');
}
@@ -160,7 +163,7 @@ function _haToGraph(btn) {
var host = (hostIn ? hostIn.value : '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/[\/:].*$/, '') || _haHostOf(g.url);
var tags = ['http-headers', g.source === 'pasted' ? 'pasted' : 'live'];
var label = host || 'pasted headers';
- var items = g.results.filter(function(r) { return r.status !== 'present'; }).map(function(r) {
+ var items = g.results.filter(function(r) { return r.status === 'missing' || r.status === 'weak'; }).map(function(r) {
return { type: 'FINDING', name: r.header.name + ' header ' + (r.status === 'missing' ? 'missing' : 'weak') + ' (' + label + ')',
data: { header: r.header.name, value: r.value || '', check: r.status, domain: host, grade: g.grade, score: g.score, recommendation: r.header.rec },
opts: { tags: tags.concat('security-header'), severity: _haSeverity(r) } };
@@ -187,6 +190,12 @@ function _haRender(resultsEl, info) {
var hdr = _haSecurityHeaders[i];
var key = hdr.name.toLowerCase();
var value = headers[key] || null;
+ if (hdr.optional) {
+ // Shown for completeness; a missing optional header costs nothing, a weak one is flagged.
+ var ow = value && hdr.weak && hdr.weak(value);
+ results.push({ header: hdr, value: value, status: ow ? 'weak' : value ? 'present' : 'optional', color: ow ? '#ffcc00' : value ? '#00ff88' : '#8a93a6', label: ow ? 'Weak' : value ? 'Present' : 'Optional' });
+ continue;
+ }
totalWeight += hdr.weight;
var status, statusColor, statusLabel;
@@ -231,6 +240,7 @@ function _haRender(resultsEl, info) {
h += '
' + (info.status ? 'HTTP ' + esc(String(info.status)) + ' ' + esc(info.statusText || '') + ' | ' : '') + 'Score: ' + score + '/100 | Headers checked: ' + _haSecurityHeaders.length + (info.timing != null ? ' | ' + info.timing + 'ms' : '') + ' | Source: ' + srcLabel + '
';
if (!isHTTPS) h += '
WARNING: Not using HTTPS (-20 points)
';
for (var di = 0; di < disclosed.length; di++) h += '
' + esc(disclosed[di].name) + ': ' + esc(disclosed[di].value) + ' (' + esc(disclosed[di].note) + ')
';
+ if ((info.masked || []).indexOf('server') !== -1) h += '
Server: not readable through the proxy (Cloudflare replaces it in transit). Use the local agent or paste curl -sI output to check it.
';
if (/^3\d\d$/.test(String(info.status)) && headers['location']) h += '
Redirects to: ' + esc(headers['location']) + ' (headers graded on this redirect response)
';
if (info.redirects && info.redirects.length) h += '
Redirect chain: ' + info.redirects.map(function(hop) { return esc(typeof hop === 'string' ? hop : ((hop.status != null ? hop.status + ' ' : '') + (hop.url || hop.from || '') + (hop.location || hop.to ? ' -> ' + (hop.location || hop.to) : ''))); }).join(' | ') + '
';
if (headers['allow']) h += '
Advertised methods (Allow): ' + esc(headers['allow']) + '
';
@@ -258,14 +268,16 @@ function _haRender(resultsEl, info) {
h += '
' + esc(res.value.substring(0, 300)) + (res.value.length > 300 ? '...' : '') + '
';
}
h += '
' + esc(res.header.desc) + '
';
- if (res.status !== 'present') {
+ if (res.status === 'optional') {
+ h += '
Not graded. ' + esc(res.header.rec) + '
';
+ } else if (res.status !== 'present') {
h += '
Recommendation: ' + esc(res.header.rec) + '
';
}
h += '
';
}
// Send issues to the Security Graph
- var issueCount = results.filter(function(x) { return x.status !== 'present'; }).length + disclosed.length;
+ var issueCount = results.filter(function(x) { return x.status === 'missing' || x.status === 'weak'; }).length + disclosed.length;
if (issueCount) {
h += '
';
if (info.source === 'pasted' && !_haHostOf(url)) h += '
';
diff --git a/public/js/ssl-inspector.js b/public/js/ssl-inspector.js
index 642c3622..9c614456 100644
--- a/public/js/ssl-inspector.js
+++ b/public/js/ssl-inspector.js
@@ -1,6 +1,26 @@
// Copyright (c) 2026 Darknode-Official. All rights reserved.
// SSL Inspector — Certificate Transparency search, subdomain discovery, issuance analysis
-// Uses crt.sh public API (no key required, CORS-friendly JSON output)
+// Uses crt.sh through the Darknode proxy, with SSLMate Cert Spotter as a fallback when crt.sh
+// is down or too slow (it often is, and a direct browser call then fails with a CORS error).
+
+import { dnFetchJSON } from "/js/net.js";
+
+// Cert Spotter issuances -> crt.sh-shaped rows, so every tab below works on either source.
+function _sslFromCertspotter(arr) {
+ return (Array.isArray(arr) ? arr : []).map(function(x) {
+ var names = x.dns_names || [];
+ return { id: x.id || '', common_name: names[0] || '', name_value: names.join('\n'), issuer_name: (x.issuer && (x.issuer.name || x.issuer.friendly_name)) || '', not_before: x.not_before || '', not_after: x.not_after || '', serial_number: x.cert_sha256 || '' };
+ });
+}
+function _sslFetchCerts(domain) {
+ return dnFetchJSON('https://crt.sh/?q=%25.' + encodeURIComponent(domain) + '&output=json', { timeout: 30000, max: 4000000 })
+ .then(function(rows) { if (!Array.isArray(rows)) throw new Error('crt.sh returned no list'); return { rows: rows, source: 'crt.sh' }; })
+ .catch(function(e1) {
+ return dnFetchJSON('https://api.certspotter.com/v1/issuances?domain=' + encodeURIComponent(domain) + '&include_subdomains=true&expand=dns_names&expand=issuer&expand=not_before&expand=not_after', { timeout: 20000 })
+ .then(function(arr) { return { rows: _sslFromCertspotter(arr), source: 'Cert Spotter (unexpired certificates only; crt.sh did not answer)' }; })
+ .catch(function(e2) { throw new Error('crt.sh: ' + (e1 && e1.message || e1) + ' / Cert Spotter: ' + (e2 && e2.message || e2)); });
+ });
+}
var esc = function(s) { return String(s != null ? s : '').replace(/[&<>"']/g, function(c) { return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); };
@@ -12,7 +32,7 @@ export function renderSSLInspector(container) {
// Header
h += '
';
h += '
SSL Inspector ';
- h += '
Search Certificate Transparency logs for a domain\'s certificates, subdomains and issuance history. Source: crt.sh (no API key).
';
+ h += '
Search Certificate Transparency logs for a domain\'s certificates, subdomains and issuance history. Source: crt.sh, or Cert Spotter when crt.sh is down (no API key).
';
h += '
';
// Search bar
@@ -71,10 +91,11 @@ function _sslSearch() {
results.innerHTML = '
Querying CT logs for ' + esc(domain) + '...
';
_sslActiveTab = 'certs';
- fetch('https://crt.sh/?q=%25.' + encodeURIComponent(domain) + '&output=json')
- .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
- .then(function(data) {
- _sslData.certs = data || [];
+ _sslFetchCerts(domain)
+ .then(function(res) {
+ if (_sslData.domain !== domain) return; // a newer search started meanwhile
+ _sslData.certs = res.rows;
+ _sslData.source = res.source;
// Extract unique subdomains from name_value fields
var subMap = {};
for (var i = 0; i < _sslData.certs.length; i++) {
@@ -93,7 +114,7 @@ function _sslSearch() {
results.innerHTML = '
' +
'
CT log query failed
' +
'
' + esc(err.message) + '
' +
- '
crt.sh may be temporarily unavailable or rate-limited
' +
+ '
Neither certificate-transparency source answered. Try again in a minute.
' +
'
';
});
}
@@ -119,7 +140,7 @@ function _sslRenderCerts(el) {
var h = '';
h += '
';
h += '
' + certs.length + ' certificates found
';
- h += '
Domain: ' + esc(_sslData.domain) + ' ' + _sslGraphBtnHTML() + '
';
+ h += '
Domain: ' + esc(_sslData.domain) + (_sslData.source ? ' | Source: ' + esc(_sslData.source) : '') + ' ' + _sslGraphBtnHTML() + '
';
h += '
';
h += '
';
@@ -294,7 +315,7 @@ function _sslToGraph(btn) {
.slice(0, 50);
var findings = _sslFindings(_sslCertStats());
var sevMap = { CRITICAL: 'critical', HIGH: 'high', MEDIUM: 'medium', LOW: 'low', INFO: 'info' };
- var tags = ['ssl', 'ct-log', 'crt.sh'];
+ var tags = ['ssl', 'ct-log', /^Cert Spotter/.test(_sslData.source || '') ? 'certspotter' : 'crt.sh'];
btn.disabled = true;
import('/js/graph-bridge.js?v=20261028c').then(function(gb) {
var rr = gb.sendToGraph('SSL Inspector', [{ type: 'DOMAIN', name: domain, data: { ctCertificates: _sslData.certs.length, ctSubdomains: _sslData.subdomains.length }, opts: { tags: tags } }], undefined, true);
diff --git a/public/js/tech-fingerprint.js b/public/js/tech-fingerprint.js
index ea7fa3f9..1a248158 100644
--- a/public/js/tech-fingerprint.js
+++ b/public/js/tech-fingerprint.js
@@ -175,9 +175,11 @@ window._tfScan = async function() {
// Server info
var server = hdr.get('server');
var poweredBy = hdr.get('x-powered-by');
- if (server || poweredBy || generator) {
+ var serverMasked = !server && (resp.masked || []).indexOf('server') !== -1;
+ if (server || serverMasked || poweredBy || generator) {
h += '
';
if (server) h += '
Server: ' + esc(server) + '
';
+ if (serverMasked) h += '
Server: not readable through the proxy (Cloudflare replaces it in transit)
';
if (poweredBy) h += '
X-Powered-By: ' + esc(poweredBy) + '
';
if (generator) h += '
Meta generator: ' + esc(generator) + '
';
h += '
';
diff --git a/test/security/recon-tools-audit.test.mjs b/test/security/recon-tools-audit.test.mjs
index 8caa2f5f..f2182b1e 100644
--- a/test/security/recon-tools-audit.test.mjs
+++ b/test/security/recon-tools-audit.test.mjs
@@ -46,9 +46,15 @@ group("ssl-inspector", () => {
let onTab; const c = mk("c"); c.addEventListener = (t, f) => { onTab = f; };
renderSSLInspector(c);
let asked = "";
- setGlobal("fetch", async (u) => { asked = u; return { ok: true, json: async () => rows }; });
+ // CT sources are fetched through the Darknode proxy (crt.sh sends no CORS headers).
+ globalThis.DARKNODE_PROXY_URL = "https://proxy.test/api/chat";
+ setGlobal("fetch", async (u) => {
+ asked = new URL(u).searchParams.get("url");
+ return { ok: true, json: async () => ({ status: 200, body: JSON.stringify(rows) }) };
+ });
els["ssl-domain"].value = input;
- globalThis._sslSearch(); await tick();
+ globalThis._sslSearch(); for (let i = 0; i < 5; i++) await tick();
+ delete globalThis.DARKNODE_PROXY_URL;
onTab({ target: { classList: { contains: () => true, add() {}, remove() {} }, style: {}, getAttribute: () => "subdomains" } });
out = { asked, html: els["ssl-results"].innerHTML };
});
diff --git a/test/worker/net-guard.test.mjs b/test/worker/net-guard.test.mjs
index efbfb320..b6650299 100644
--- a/test/worker/net-guard.test.mjs
+++ b/test/worker/net-guard.test.mjs
@@ -1,6 +1,6 @@
// Unit tests for worker/net-guard.js — the shared SSRF host guard.
import { test, group, assert } from "../harness.mjs";
-import { hostAllowed, ipv4Private, normalizeIpv4 } from "../../worker/net-guard.js";
+import { hostAllowed, ipv4Private, normalizeIpv4, ipOnCloudflare } from "../../worker/net-guard.js";
const allowed = (url) => hostAllowed(new URL(url));
@@ -70,3 +70,12 @@ group("net-guard/hostAllowed", () => {
assert.notOk(ipv4Private("8.8.8.8"));
});
});
+
+group("net-guard/ipOnCloudflare", () => {
+ test("addresses inside Cloudflare's published ranges, including the range edges", () => {
+ for (const ip of ["104.16.0.1", "172.67.1.1", "131.0.75.255", "2606:4700::6810:84e5", "2a06:98c7::1"]) assert.ok(ipOnCloudflare(ip), ip);
+ });
+ test("other hosts are not, so the proxy drops Cloudflare's stamped headers for them", () => {
+ for (const ip of ["199.36.158.100", "142.250.72.4", "131.0.76.0", "2a06:98d0::1", "2001:db8::1", "::1", "", "not-an-ip"]) assert.notOk(ipOnCloudflare(ip), ip);
+ });
+});
diff --git a/worker/net-guard.js b/worker/net-guard.js
index 3b81a05f..e9c414d0 100644
--- a/worker/net-guard.js
+++ b/worker/net-guard.js
@@ -68,3 +68,25 @@ export function hostAllowed(u) {
}
return true;
}
+
+// Cloudflare's published edge ranges (cloudflare.com/ips-v4, /ips-v6). A Worker's outbound
+// fetch comes back with Cloudflare's own `server: cloudflare`, `cf-ray` and `cf-cache-status`
+// whatever the origin is, so the fetch proxy uses this to tell a site really behind
+// Cloudflare from one whose headers Cloudflare stamped on the way through.
+const CF_V4 = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22"]
+ .map((c) => { const [ip, bits] = c.split("/"); const n = ip.split(".").reduce((a, o) => a * 256 + Number(o), 0); const m = Number(bits); return [n - (n % 2 ** (32 - m)), 2 ** (32 - m)]; });
+// [first hextet, second hextet, prefix bits beyond the first 16]
+const CF_V6 = [[0x2400, 0xcb00, 16], [0x2606, 0x4700, 16], [0x2803, 0xf800, 16], [0x2405, 0xb500, 16], [0x2405, 0x8100, 16], [0x2a06, 0x98c0, 13], [0x2c0f, 0xf248, 16]];
+
+export function ipOnCloudflare(ip) {
+ const s = String(ip || "").trim().toLowerCase();
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(s)) {
+ const n = s.split(".").reduce((a, o) => a * 256 + Number(o), 0);
+ return CF_V4.some(([base, size]) => n >= base && n < base + size);
+ }
+ if (!s.includes(":")) return false;
+ const head = s.split("::")[0].split(":");
+ if (head.length < 2 || !head[0] || !head[1]) return false; // the first two hextets must be spelled out
+ const a = parseInt(head[0], 16), b = parseInt(head[1], 16);
+ return CF_V6.some(([x, y, bits]) => a === x && (b >> (16 - bits)) === (y >> (16 - bits)));
+}
diff --git a/worker/worker.js b/worker/worker.js
index 9d65948a..ed2e1111 100644
--- a/worker/worker.js
+++ b/worker/worker.js
@@ -21,7 +21,7 @@ import { handleVault } from "./vault.js";
import { handleGraph } from "./graph.js";
import { handleMonitor } from "./monitor.js";
import { handleKeys, handleIngest } from "./ingest.js";
-import { hostAllowed } from "./net-guard.js";
+import { hostAllowed, ipOnCloudflare } from "./net-guard.js";
import { readCapped } from "./body-cap.js";
import { handleIntel } from "./intel.js";
import { matchRoute, authenticate, authorize, attachPrincipal, rateCheck, ipCeiling, requestId, withHeaders } from "./auth.js";
@@ -193,6 +193,28 @@ async function safeFetchUrl(rawUrl) {
return { url: rawUrl, error: "too many redirects" };
}
+// Is this host really served from Cloudflare? Resolves A (then AAAA) over DoH and checks the
+// published ranges. null when it cannot tell; cached per isolate for 10 minutes.
+const _cfHostCache = new Map();
+async function hostOnCloudflare(host) {
+ const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, "");
+ if (/^[\d.]+$/.test(h) || h.includes(":")) return ipOnCloudflare(h);
+ const hit = _cfHostCache.get(h);
+ if (hit && hit.exp > Date.now()) return hit.v;
+ let v = null;
+ try {
+ for (const [type, num] of [["A", 1], ["AAAA", 28]]) {
+ const r = await fetch("https://cloudflare-dns.com/dns-query?name=" + encodeURIComponent(h) + "&type=" + type, { headers: { Accept: "application/dns-json" } });
+ if (!r.ok) break;
+ const ips = (((await r.json()) || {}).Answer || []).filter((a) => a.type === num).map((a) => a.data);
+ if (ips.length) { v = ips.some(ipOnCloudflare); break; }
+ }
+ } catch (_) { v = null; }
+ if (_cfHostCache.size > 500) _cfHostCache.clear();
+ _cfHostCache.set(h, { v, exp: Date.now() + 600000 });
+ return v;
+}
+
// Generic SSRF-guarded fetch proxy for the browser tools (recon/OSINT/HTTP
// inspection). Unlike safeFetchUrl (tuned for AI text enrichment) this preserves
// the REAL response status + headers so tools like header-analyzer, http-probe
@@ -282,7 +304,14 @@ async function handleFetch(request) {
// A body cut to `cap` is truncated too, not only one cut at the read ceiling.
if (full.length > cap) truncated = true;
const text = full.slice(0, cap);
- return json({ url: u.href, finalUrl: u.href, status: res.status, statusText: res.statusText || "", contentType: ct, headers, redirectChain: chain, body: text, bytes, truncated });
+ // Cloudflare stamps its own server / cf-ray / cf-cache-status on every Worker subrequest,
+ // replacing the origin's Server header. Keep them only for hosts really behind Cloudflare.
+ let masked;
+ if (/^cloudflare$/i.test(headers["server"] || "") && (await hostOnCloudflare(u.hostname)) === false) {
+ delete headers["server"]; delete headers["cf-ray"]; delete headers["cf-cache-status"];
+ masked = ["server"];
+ }
+ return json({ url: u.href, finalUrl: u.href, status: res.status, statusText: res.statusText || "", contentType: ct, headers, redirectChain: chain, body: text, bytes, truncated, ...(masked ? { masked } : {}) });
}
return json({ error: "too many redirects" }, 502);
}