From b42d665f41eca68da18343182fcd02928b52a3e5 Mon Sep 17 00:00:00 2001 From: Darknode-Official Date: Mon, 2 Nov 2026 18:48:59 -0800 Subject: [PATCH 1/3] Fix proxy Cloudflare header stamping, nested SPF count, CT fallback - Worker /api/fetch: Cloudflare stamps server: cloudflare, cf-ray and cf-cache-status on every Worker subrequest, so every site fingerprinted as Cloudflare and was flagged for disclosing a Server header. Drop them for hosts outside Cloudflare's ranges and report masked: ["server"]. - Tech Fingerprint / Website Headers: say the Server value is not readable through the proxy instead of showing Cloudflare's. - Email Security: count SPF DNS lookups through every include/redirect. - Website Headers: legacy/optional headers are shown but not graded. - Certificate Search: crt.sh through the proxy, Cert Spotter fallback. - DNS Lookup: MX and SRV records sorted by priority. --- public/js/dns-enum.js | 2 + public/js/email-auth.js | 50 ++++++++++++++++++++++++ public/js/header-analyzer.js | 30 +++++++++----- public/js/ssl-inspector.js | 39 +++++++++++++----- public/js/tech-fingerprint.js | 4 +- test/security/recon-tools-audit.test.mjs | 10 ++++- test/worker/net-guard.test.mjs | 11 +++++- worker/net-guard.js | 22 +++++++++++ worker/worker.js | 33 +++++++++++++++- 9 files changed, 177 insertions(+), 24 deletions(-) diff --git a/public/js/dns-enum.js b/public/js/dns-enum.js index cec77c83..dc3345dc 100644 --- a/public/js/dns-enum.js +++ b/public/js/dns-enum.js @@ -62,6 +62,8 @@ async function _deDohRecords(domain) { const rev = A[0].value.split('.').reverse().join('.') + '.in-addr.arpa'; PTR = await q(rev, 'PTR'); } + const byPri = (a, b) => (a.priority - b.priority) || a.value.localeCompare(b.value); + MX.sort(byPri); srv.sort((a, b) => a.name.localeCompare(b.name) || byPri(a, b)); Object.assign(records, { A, AAAA, MX, NS, TXT: TXT.concat(dmarc), CNAME: cnApex.concat(cnWww), SOA, SRV: srv, PTR, CAA }); // Record types whose lookup failed (vs. genuinely empty), for the Records view to flag. Object.defineProperty(records, 'failed', { value: [...failed], enumerable: false }); diff --git a/public/js/email-auth.js b/public/js/email-auth.js index 45fa7384..f765b555 100644 --- a/public/js/email-auth.js +++ b/public/js/email-auth.js @@ -307,8 +307,58 @@ function analyzeDKIM(results) { // ── LOOKUP JOBS — glue transport to parsers; resolve { net, result } ── // net=false means the lookup failed at the transport level (not cached, so a retry can succeed). +// The RFC 7208 limit of 10 DNS lookups counts the lookups inside every include: and +// redirect= target too, so the top-level count from analyzeSPF is only a lower bound. +// Fetch the include chain and replace that estimate with the real total. +var SPF_LIMIT_RE = /^(include: count = |SPF allows a maximum of 10|Close to the SPF 10)/; +function spfTargets(txts) { + var out = []; + (txts || []).forEach(function (t) { + if (!/^v=spf1(\s|$)/i.test(String(t).trim())) return; + String(t).split(/\s+/).forEach(function (tok) { + var m = tok.replace(/^[-~?+]/, "").match(/^(?:include:|redirect=)(.+)$/i); + if (m && !/%\{/.test(m[1])) out.push(m[1].toLowerCase().replace(/\.$/, "")); + }); + }); + return out; +} +function spfDeepCount(domain, txts, result, signal) { + var records = {}, queue = spfTargets(txts), seen = new Set([domain]), q = 0; + records[domain] = txts; + function step() { + var batch = []; + while (queue.length && batch.length < 8 && q < 40) { var n = queue.shift(); if (!seen.has(n)) { seen.add(n); batch.push(n); q++; } } + if (!batch.length) return Promise.resolve(); + return Promise.all(batch.map(function (n) { + return dohTXT(n, signal).then(function (r) { if (r.ok) { records[n] = r.txt; queue.push.apply(queue, spfTargets(r.txt)); } }); + })).then(step); + } + return step().then(function () { + return import("/js/exposure/dmarcreports-engine.js").then(function (m) { + var t = m.spfTotalLookups(domain, records); + var f = result.findings.filter(function (x) { return !SPF_LIMIT_RE.test(x.text); }); + var unresolved = t.unresolved.length ? " " + t.unresolved.length + " include(s) could not be resolved, so the real number may be higher." : ""; + f.push({ kind: "info", text: "DNS lookups counted through every include and redirect: " + t.total + " of the 10 allowed." + unresolved }); + var status = result.status; + if (t.total > 10) { + f.push({ kind: "bad", text: "This SPF record needs " + t.total + " DNS lookups including nested includes; receivers stop at 10 and return permerror, so SPF fails for all mail. Flatten or remove includes." }); + status = "FAIL"; + } else if (t.total >= 8) { + f.push({ kind: "warn", text: "Close to the SPF limit: " + t.total + " of 10 DNS lookups including nested includes." }); + if (status === "PASS") status = "WARN"; + } + return { status: status, record: result.record, findings: f }; + }); + }).catch(function () { return result; }); // keep the top-level estimate if the walk fails +} + function spfJob(domain, signal) { return dohTXT(domain, signal).then(function (r) { + if (r.ok) { + var base = analyzeSPF(r.txt); + if (!base.record) return { net: true, result: base }; + return spfDeepCount(domain, r.txt, base, signal).then(function (res) { return { net: true, result: res }; }); + } return r.ok ? { net: true, result: analyzeSPF(r.txt) } : { net: false, result: { status: "FAIL", record: null, findings: [{ kind: "bad", text: "SPF lookup failed: " + r.error }] } }; 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); } From 4b7d268d604e1d53ce9d69b357b688ad8597c66d Mon Sep 17 00:00:00 2001 From: Darknode-Official Date: Mon, 2 Nov 2026 19:05:58 -0800 Subject: [PATCH 2/3] CVE search newest-first, email header DKIM alignment, security.txt contact - CVE Search: keyword matches came back oldest first (openssh showed 1999 CVEs and never the recent ones). Read the last NVD page and sort newest first; the counter no longer reads "25 of 200 of 200 results". - Email Header Analyzer: flag SPF softfail; read every DKIM signing domain and flag a pass that is not aligned with the From domain; a later hop with no "from" is an internal handoff, not the origin. - security.txt: Contact and Policy pointed at /education. Use security@darknode.ai and /contact (what the Contact page tells researchers), with Expires under a year out per RFC 9116. --- public/.well-known/security.txt | 7 ++++--- public/js/cve-search.js | 16 ++++++++++++---- public/js/email-header.js | 26 +++++++++++++++++++++++--- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt index c9c42a6d..c0c94938 100644 --- a/public/.well-known/security.txt +++ b/public/.well-known/security.txt @@ -1,5 +1,6 @@ -Contact: https://darknode.ai/education -Expires: 2027-12-31T23:59:59.000Z +Contact: mailto:security@darknode.ai +Contact: https://darknode.ai/contact +Expires: 2027-10-31T23:59:59.000Z Preferred-Languages: en Canonical: https://darknode.ai/.well-known/security.txt -Policy: https://darknode.ai/education +Policy: https://darknode.ai/contact diff --git a/public/js/cve-search.js b/public/js/cve-search.js index 8b57882c..b2d83085 100644 --- a/public/js/cve-search.js +++ b/public/js/cve-search.js @@ -212,17 +212,25 @@ window._cveDoSearch = function() { if (/^CVE-\d{4}-\d{4,}$/i.test(query)) { url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=' + encodeURIComponent(query.toUpperCase()); } else { - url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=' + encodeURIComponent(query) + '&resultsPerPage=20'; + url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=' + encodeURIComponent(query) + '&resultsPerPage=' + _CVE_LATEST_N; } + // NVD lists keyword matches oldest first: when there are more than one page, read the + // last page instead so the newest matches are the ones shown. _cveFetch(url) + .then(function(data) { + var total = data.totalResults || 0, got = (data.vulnerabilities || []).length; + if (total <= got || !/keywordSearch=/.test(url)) return data; + return _cveFetch(url + '&startIndex=' + Math.max(0, total - _CVE_LATEST_N)).then(function(last) { last.totalResults = total; return last; }); + }) .then(function(data) { if (tok !== _cveTok) return; - var vulns = data.vulnerabilities || []; + var vulns = (data.vulnerabilities || []).filter(function(v) { return v && v.cve && v.cve.id; }); + vulns.sort(function(a, b) { return String(b.cve.published || '').localeCompare(String(a.cve.published || '')) || String(b.cve.id).localeCompare(String(a.cve.id)); }); var n = data.totalResults || vulns.length; _cveLastResults = vulns; _cveLimit = _CVE_PAGE; - _cveSetSrc(n + ' NVD match' + (n === 1 ? '' : 'es') + ' for "' + query + '"' + (n > vulns.length ? ' (showing the first ' + vulns.length + ')' : '') + '.'); + _cveSetSrc(n + ' NVD match' + (n === 1 ? '' : 'es') + ' for "' + query + '"' + (n > vulns.length ? ' (showing the newest ' + vulns.length + ')' : ', newest first') + '.'); _cveRenderList(results, vulns); }) .catch(function(err) { if (tok === _cveTok) _cveErr(results, err); }); @@ -261,7 +269,7 @@ function _cveRenderList(el, vulns) { } var shown = Math.min(filtered.length, _cveLimit); - var h = '
' + (shown < filtered.length ? 'Showing ' + shown + ' of ' : '') + filtered.length + ' of ' + vulns.length + ' results
'; + var h = '
' + (shown < filtered.length ? 'Showing ' + shown + ' of ' : '') + filtered.length + (filtered.length < vulns.length ? ' of ' + vulns.length : '') + ' results' + (filtered.length < vulns.length ? ' (severity filter)' : '') + '
'; for (var i = 0; i < shown; i++) { var cve = filtered[i].cve; diff --git a/public/js/email-header.js b/public/js/email-header.js index 5a868404..24a12005 100644 --- a/public/js/email-header.js +++ b/public/js/email-header.js @@ -118,8 +118,20 @@ function parseAuth(val) { var results = { spf: 'none', dkim: 'none', dmarc: 'none', spfDetail: '', dkimDetail: '', dmarcDetail: '' }; var spf = val.match(/spf=(\w+)(?:\s*\(([^)]*)\))?/i); if (spf) { results.spf = spf[1].toLowerCase(); results.spfDetail = spf[2] || ''; } - var dkim = val.match(/dkim=(\w+)/i); - if (dkim) { results.dkim = dkim[1].toLowerCase(); } + // Every DKIM result with its signing domain (header.d=, or the domain of header.i=); + // one passing signature is enough for DKIM to count as pass. + results.dkimSigs = []; + var dre = /dkim=(\w+)([^;]*)/gi, dm; + while ((dm = dre.exec(val))) { + var dd = (/header\.d=([\w.-]+)/i.exec(dm[2]) || /header\.i=[^@\s;]*@([\w.-]+)/i.exec(dm[2]) || [])[1] || ''; + results.dkimSigs.push({ result: dm[1].toLowerCase(), domain: dd.toLowerCase() }); + } + if (results.dkimSigs.length) { + var good = results.dkimSigs.filter(function(x) { return x.result === 'pass'; }); + results.dkim = good.length ? 'pass' : results.dkimSigs[0].result; + results.dkimDetail = results.dkimSigs.filter(function(x) { return x.domain; }).map(function(x) { return x.domain + ' (' + x.result + ')'; }).join(', '); + if (results.dkimDetail) results.dkimDetail = 'Signing domain: ' + results.dkimDetail; + } var dmarc = val.match(/dmarc=(\w+)(?:\s*\(([^)]*)\))?/i); if (dmarc) { results.dmarc = dmarc[1].toLowerCase(); results.dmarcDetail = dmarc[2] || ''; } return results; @@ -159,7 +171,15 @@ function detectSuspicious(headers, hops, auth) { var pol = ((auth.dmarcDetail.match(/\bp=(\w+)/i) || [])[1] || '').toLowerCase(); flags.push({ severity: 'critical', text: 'DMARC check failed. ' + (pol === 'reject' ? 'The domain asks receivers to reject this email.' : pol === 'quarantine' ? 'The domain asks receivers to send this email to spam.' : pol === 'none' ? 'The domain only monitors (p=none), so it was not blocked.' : 'The From domain could not be verified.') }); } + if (auth.spf === 'softfail') flags.push({ severity: 'medium', text: 'SPF softfail. The sending server is not listed as authorized for the envelope domain, and the domain only asks for this to be marked, not rejected.' }); if (auth.dkim === 'fail') flags.push({ severity: 'high', text: 'DKIM signature failed. Email may have been altered in transit.' }); + if (auth.dkim === 'pass' && auth.dmarc !== 'pass' && from) { + var fD = addrDomain(from); + var signers = (auth.dkimSigs || []).filter(function(x) { return x.result === 'pass' && x.domain; }).map(function(x) { return x.domain; }); + if (fD && signers.length && !signers.some(function(d) { return sameOrg(d, fD); })) { + flags.push({ severity: 'high', text: 'DKIM passed, but the signature is from ' + esc(signers.join(', ')) + ', not the From domain (' + esc(fD) + '). It proves nothing about the visible sender.' }); + } + } if (/urgent|immediate|wire transfer|confidential|act now|verify your account|suspended/i.test(subject)) { flags.push({ severity: 'medium', text: 'Subject line contains urgency/social engineering language: "' + esc(subject.substring(0, 60)) + '"' }); } @@ -282,7 +302,7 @@ export function renderEmailHeader(container) { contentHtml += '
' + '
' + (i + 1) + '
' + '
' + - '
' + esc(h.from || 'Origin') + ' → ' + esc(h.by || '?') + '
' + + '
' + esc(h.from || (i === 0 ? 'Origin' : 'Internal handoff')) + ' → ' + esc(h.by || '?') + '
' + '
' + (h.ip ? 'IP: ' + esc(h.ip) + ' · ' : '') + (h.proto ? 'Protocol: ' + esc(h.proto) + ' · ' : '') + From 0669a729c6b37f5714649b691897a3b9a3f9cc8d Mon Sep 17 00:00:00 2001 From: Darknode-Official Date: Mon, 2 Nov 2026 19:16:13 -0800 Subject: [PATCH 3/3] Release record 2026-11-02h --- docs/releases/2026-11-02h.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/releases/2026-11-02h.md diff --git a/docs/releases/2026-11-02h.md b/docs/releases/2026-11-02h.md new file mode 100644 index 00000000..67136b24 --- /dev/null +++ b/docs/releases/2026-11-02h.md @@ -0,0 +1,26 @@ +# Release 2026-11-02h +Build: f5a83ece4231 Worker: f85b54d3 (staging ee028cff; rollback eae17c13) Tag: v2026.11.02h + +## What changed (user-visible) +- Fetch proxy: Cloudflare stamps `server: cloudflare`, `cf-ray` and `cf-cache-status` on every Worker subrequest. Every site fingerprinted as Cloudflare, and Website Headers flagged a Server disclosure everywhere. The proxy now drops those headers for hosts outside Cloudflare's published ranges and returns `masked: ["server"]`; Tech Fingerprint and Website Headers say the Server value is not readable through the proxy. +- Email Security: SPF DNS lookups counted through every include and redirect. +- Website Headers: legacy/optional headers (X-XSS-Protection, CORP, COEP, X-DNS-Prefetch-Control, X-Permitted-Cross-Domain-Policies) shown but not graded. +- Certificate Search: crt.sh through the proxy (was blocked by CORS), Cert Spotter fallback. +- DNS Lookup: MX/SRV sorted by priority. +- CVE Search: keyword matches newest first (was the oldest 20). +- Email Header Analyzer: SPF softfail flagged; DKIM pass not aligned with From flagged; internal hops labeled. +- security.txt: Contact/Policy point to security@darknode.ai and /contact (were /education); Expires under a year. + +## Security / rules / data changes +- No rules changes. Worker /api/fetch adds one DoH lookup (cached 10 min per isolate) for responses carrying `server: cloudflare`. + +## Verified (how) +- npm test 4995/0; release:check READY. +- Staging and prod proxy: google.com/github.com/darknode.ai masked, cloudflare.com/discord.com keep Cloudflare headers. +- Local Deck: each Quick Check run on real domains; live build.json, CSP, security.txt and cve-search.js checked. + +## Skipped or known issues +- Owner: confirm the security@darknode.ai forward exists at Porkbun (contact@ is the one used elsewhere). + +## Owner decisions still open +- Server plan gating for monitors/sync/keys; ban list readable by signed-in users.