diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..06560208 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,78 @@ +# ScopeWeave product–technical gap baseline + +This file is the repository-facing snapshot of commercial product gaps that must stay aligned with executable contracts. It is not a release certificate. Live PR head, protected-base, checks, reviews, and release state must be re-read from GitHub rather than copied here as durable authority. + +## Product boundary + +ScopeWeave owns schedule-control truth for WBS planning, progress, EVM/S-curve, CPM, baselines/history, and the SaaS collaboration layer described by the repository README. In cloud mode it also owns the workspace-scoped webhook subscription and delivery record. It does not own general outbound-network policy for the ContextualWisdomLab ecosystem. + +Relevant bounded contexts for the current security slice are: + +- **Schedule Control** — Project/WBS/Baseline domain truth and project mutation invariants. +- **Workspace Collaboration** — tenant membership, RBAC, project collaboration, and audit scope. +- **Webhook Delivery** — workspace-scoped subscription, HMAC signing, retry, and delivery evidence. +- **Outbound Network ACL** — an anti-corruption boundary at the transport seam. ScopeWeave must either enforce the webhook-specific destination invariant locally or consume an immutable released EgressWeave contract; it must not copy a mutable sibling implementation or query sibling storage. + +The Project aggregate must not become transactionally coupled to outbound delivery. A webhook destination rejection or transport failure records/omits delivery according to the existing webhook contract and does not roll back the triggering Project mutation. + +## Current executable state and remaining gap + +The active webhook-hardening lineage now establishes these source/test facts: + +- registration requires HTTPS, rejects embedded credentials and special-use literal destinations, and delivery revalidates the persisted URL; +- `server/webhook_destination.mjs` owns the shared URL/address admission and injected DNS lookup boundary; +- the webhook-only Undici `Agent` consumes that lookup, so all A/AAAA answers are checked before one admitted address is returned directly to the socket lookup; +- webhook delivery uses a per-request dispatcher with `redirect: 'error'`; unrelated OIDC/global Fetch traffic is not routed through the webhook policy; +- `tests/api/webhook-ssrf.test.mjs` exercises the exported isolated webhook agent directly instead of monkeypatching every Undici Agent, and covers special-use literals, mixed DNS answers, exact selected-address return, persisted invalid destination rejection, a real 302 carrying a private `Location`, delivery receipts, and one retry; +- the address policy rejects deprecated IPv4-compatible IPv6 `::/96`, IPv4-mapped `::ffff:0:0/96`, and the RFC 8215 local-use translation prefix `64:ff9b:1::/48`; +- RFC 6052 `64:ff9b::/96` is evaluated by its embedded IPv4 destination rather than blanket-denied. Public embedded destinations remain admissible; private, loopback, documentation, benchmark, multicast, and otherwise non-public embedded destinations fail closed; +- `server/webhook_destination.mjs` is explicitly inside the owned c8 instrumentation denominator. + +The source-level P0 boundary is implemented on the active branch, but it is not a release GREEN. Hosted correctness/coverage/security/static-analysis checks and independent current-head review remain required on one unchanged exact head. PR #649 remains a divergent evidence lane and must not be closed as a duplicate until a successor is verified to inherit every valid NAT64/address/transport/application-retry fixture and documentation delta. Normal descendants that improve test isolation are adopted; descendants that regress standards-correct address semantics or owned coverage are repaired without rewriting history. + +## Security invariant and acceptance + +For each webhook delivery: + +1. Parse the persisted destination and require HTTPS with no embedded userinfo. +2. Resolve the original hostname once for the transport attempt and obtain all A/AAAA answers. +3. Fail closed if any resolved address is outside the repository's admitted public-address policy. The policy must stay aligned with IANA and applicable standards: `64:ff9b::/96` is globally reachable but RFC 6052 forbids using it for non-global embedded IPv4 destinations, while `64:ff9b:1::/48` is local-use and not globally reachable. +4. Select an admitted address deterministically and bind that exact address to the socket connection while preserving the original hostname for HTTP Host and TLS/SNI verification. +5. Do not follow HTTP redirects implicitly. A deterministic local 3xx fixture must prove that a `Location` header cannot create a second unvalidated hop. +6. Preserve request body, HMAC signature, timeout/cancellation, retry, tenant scope, and delivery-record semantics. +7. Keep webhook transport policy local to this request path; do not install a process-global dispatcher to make a leaf test pass. +8. Carry negative controls for special-use/translated private destinations and positive controls for representative globally routable IPv4/IPv6 destinations so hardening cannot silently become an allow-nothing policy. +9. Keep the outbound-policy module inside owned production coverage and retain deterministic edge cases for each translation/address family used as security authority. + +A GREEN requires the focused SSRF/API regression, supported Node install/test/coverage path, Security/SAST/CodeQL gates, and an independent current-head review. Local source inspection or predecessor GREEN is not a substitute for that exact-head evidence. + +## DDD / data / operability implications + +`Webhook` subscription identity and delivery evidence remain workspace-scoped. Destination validation is a domain service / ACL at the outbound boundary, not a property of the Project aggregate and not cross-service SQL. Delivery attempts must remain idempotent with respect to the existing retry identity and must not silently turn security rejection into successful delivery evidence. + +The current development database uses `node:sqlite`; production database substitution must preserve tenant/RBAC/webhook invariants and migration behavior. This gap does not authorize database denormalization, cross-tenant indexes without evidence, or a mutable sibling dependency. + +Operational evidence for release must include timeout/cancellation cleanup and connection lifecycle closure in addition to HTTP status. If a future external EgressWeave release replaces the local ACL, ScopeWeave must pin an immutable released version and retain consumer contract tests for the same destination/DNS/connection/redirect invariants. + +## Buyer-visible gap order + +P0 is exact-head verification and consolidation of the implemented connection-time SSRF authority without losing valid #649 evidence. P1 is immutable delivery evidence that distinguishes destination-policy rejection, DNS-resolution rejection, redirect rejection, timeout/cancellation, transport failure, and remote HTTP failure without leaking secrets. P2 is a realistic, right-cleared SaaS rehearsal covering webhook creation, project mutation, signed delivery, one retry, delivery log inspection, secret rotation, and failure recovery under the supported deployment stack. + +No buyer-facing p95 ≤20 ms statement is made for webhook delivery: the operation is external-I/O bound and must preserve security/timeout correctness. Applicable buyer page/API performance claims still require measured k6/E2E evidence on the actual interactive request path rather than sample reduction or unrealistic cache warm-up. + +## Traceability + +Repository evidence for this snapshot is the active webhook-hardening PR and its executable test/module lineage. The documentation deliberately avoids freezing a self-referential current-head SHA; use live GitHub PR/check APIs when collecting exact-head evidence. + +Primary references: + +- Internet Assigned Numbers Authority. (2025). *IPv6 special-purpose address space*. IANA. `64:ff9b::/96` is marked globally reachable; `64:ff9b:1::/48` is not. https://www.iana.org/assignments/iana-ipv6-special-registry +- Bao, C., Huitema, C., Bagnulo, M., Boucadair, M., & Li, X. (2010). *RFC 6052: IPv6 addressing of IPv4/IPv6 translators*. Internet Engineering Task Force. The Well-Known Prefix is `64:ff9b::/96`, with the IPv4 destination in the low-order 32 bits; the WKP must not represent non-global IPv4 destinations. https://www.rfc-editor.org/rfc/rfc6052 +- Anderson, T. (2017). *RFC 8215: Local-use IPv4/IPv6 translation prefix*. Internet Engineering Task Force. `64:ff9b:1::/48` is reserved for local use and is not globally reachable. https://www.rfc-editor.org/rfc/rfc8215 +- Hinden, R., & Deering, S. (2006). *RFC 4291: IP Version 6 Addressing Architecture*. Internet Engineering Task Force. IPv4-Compatible IPv6 addresses are deprecated. https://www.rfc-editor.org/rfc/rfc4291 +- Blanchet, M. (2008). *RFC 5156: Special-Use IPv6 Addresses*. Internet Engineering Task Force. IPv4-compatible and IPv4-mapped forms are not public-Internet destination authority. https://www.rfc-editor.org/rfc/rfc5156 +- WHATWG. (2026). *Fetch Standard*. Redirect mode is explicitly `follow`, `error`, or `manual`; outbound code that does not support redirects must select a non-follow mode. https://fetch.spec.whatwg.org/ + +## Release gate + +A source fix is not a release. Promotion requires normal protected-branch integration plus current version/CHANGELOG, immutable tag/package or deployment artifact as applicable, SBOM, provenance, reproducibility evidence, rollback/recovery procedure, and the repository/organization-required review and security gates on the exact protected generation. This document must be revisited when those facts change. diff --git a/package-lock.json b/package-lock.json index 00a99254..78682a36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^2.1.1", - "hono": "^4.13.0" + "hono": "^4.13.0", + "undici": "^7.29.1" }, "devDependencies": { "@playwright/test": "1.62.1", @@ -739,6 +740,15 @@ "node": "20 || >=22" } }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 8cefdc74..cf98a206 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/webhook-ssrf.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/webhook_destination.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", @@ -24,7 +24,8 @@ }, "dependencies": { "@hono/node-server": "^2.1.1", - "hono": "^4.13.0" + "hono": "^4.13.0", + "undici": "^7.29.1" }, "devDependencies": { "@playwright/test": "1.62.1", diff --git a/server/app.mjs b/server/app.mjs index c432a84f..6776b725 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,3 +1,11 @@ +import { Agent, fetch as undiciFetch } from "undici"; +import { createSafeWebhookLookup, isSafeWebhookUrl } from "./webhook_destination.mjs"; + +export const safeWebhookAgent = new Agent({ + connect: { + lookup: createSafeWebhookLookup() + } +}); // ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on // project docs, SSE realtime fan-out per project. The existing static client // (index.html/app.js) becomes the frontend that talks to these routes. @@ -103,11 +111,13 @@ function sendWebhook(webhookId, url, sig, event, body, attempt) { metrics.webhookDeliveries++; const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 3000); - fetch(url, { + undiciFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, body, signal: ctrl.signal, + dispatcher: safeWebhookAgent, + redirect: 'error', }).then((res) => { recordDelivery(webhookId, event, res.status, res.ok, attempt); if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); @@ -122,9 +132,10 @@ function deliver(orgId, event, payload) { try { hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); } catch { return; } - for (const h of hooks) { +for (const h of hooks) { const subs = String(h.events || '').split(',').map((s) => s.trim()); if (!(subs.includes('*') || subs.includes(event))) continue; + if (!isSafeWebhookUrl(String(h.url))) continue; const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); const sig = createHmac('sha256', h.secret).update(body).digest('hex'); sendWebhook(h.id, h.url, sig, event, body, 1); @@ -748,6 +759,7 @@ app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); const { url, events } = await c.req.json().catch(() => ({})); if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + if (!isSafeWebhookUrl(String(url))) return c.json({ error: 'internal or private url forbidden' }, 400); const secret = `whsec_${randomBytes(24).toString('base64url')}`; const evs = Array.isArray(events) ? events.join(',') : (events || '*'); const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); diff --git a/server/webhook_destination.mjs b/server/webhook_destination.mjs new file mode 100644 index 00000000..08492d9c --- /dev/null +++ b/server/webhook_destination.mjs @@ -0,0 +1,131 @@ +import dns from 'node:dns'; +import net from 'node:net'; + +const blockedWebhookIpv4 = new net.BlockList(); +for (const [network, prefix] of [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +]) { + blockedWebhookIpv4.addSubnet(network, prefix, 'ipv4'); +} + +const blockedWebhookIpv6 = new net.BlockList(); +for (const [network, prefix] of [ + ['::', 96], + ['::1', 128], + ['::ffff:0:0', 96], + ['64:ff9b:1::', 48], + ['100::', 64], + ['2001:db8::', 32], + ['2001:10::', 28], + ['2001:20::', 28], + ['2002::', 16], + ['fc00::', 7], + ['fe80::', 10], + ['fec0::', 10], + ['ff00::', 8], +]) { + blockedWebhookIpv6.addSubnet(network, prefix, 'ipv6'); +} + +const rfc6052WellKnownPrefix = new net.BlockList(); +rfc6052WellKnownPrefix.addSubnet('64:ff9b::', 96, 'ipv6'); + +function normalizeHostname(hostname) { + const host = String(hostname || '').trim().toLowerCase(); + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; +} + +function expandIpv6Words(address) { + const halves = address.toLowerCase().split('::'); + const parseHalf = (half) => { + if (!half) return []; + return half.split(':').flatMap((part) => { + if (!part.includes('.')) return [Number.parseInt(part, 16)]; + const octets = part.split('.').map(Number); + return [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]]; + }); + }; + const left = parseHalf(halves[0]); + const right = parseHalf(halves[1] || ''); + const zeroCount = halves.length === 2 ? 8 - left.length - right.length : 0; + return halves.length === 2 + ? [...left, ...Array(zeroCount).fill(0), ...right] + : left; +} + +function rfc6052EmbeddedIpv4(address) { + const words = expandIpv6Words(address); + if (words.length !== 8) return null; + return [words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff].join('.'); +} + +export function isPublicWebhookIp(address) { + const family = net.isIP(address); + if (family === 0) return false; + if (family === 6 && rfc6052WellKnownPrefix.check(address, 'ipv6')) { + const embeddedIpv4 = rfc6052EmbeddedIpv4(address); + return embeddedIpv4 !== null && isPublicWebhookIp(embeddedIpv4); + } + if (family === 4) return !blockedWebhookIpv4.check(address, 'ipv4'); + return !blockedWebhookIpv6.check(address, 'ipv6'); +} + +export function isSafeWebhookUrl(urlString) { + try { + const url = new URL(urlString); + if (url.protocol !== 'https:' || url.username || url.password) return false; + const hostname = normalizeHostname(url.hostname); + if (!hostname || hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) { + return false; + } + return net.isIP(hostname) === 0 || isPublicWebhookIp(hostname); + } catch { + return false; + } +} + +function selectPublicWebhookAddress(addresses) { + if (!Array.isArray(addresses) || addresses.length === 0) throw new Error('No addresses found'); + let selected = null; + for (const candidate of addresses) { + const address = candidate?.address; + const family = Number(candidate?.family); + if ((family !== 4 && family !== 6) || net.isIP(address) !== family || !isPublicWebhookIp(address)) { + throw new Error('SSRF blocked'); + } + if (selected === null) selected = { address, family }; + } + return selected; +} + +export function createSafeWebhookLookup(resolve = dns.lookup) { + return (hostname, options, callback) => { + const callerOptions = options && typeof options === 'object' ? options : {}; + const lookupOptions = { ...callerOptions, family: 0, all: true }; + resolve(hostname, lookupOptions, (error, addresses) => { + if (error) return callback(error); + let selected; + try { + selected = selectPublicWebhookAddress(addresses); + } catch (selectionError) { + return callback(selectionError); + } + if (callerOptions.all === true) return callback(null, [selected]); + return callback(null, selected.address, selected.family); + }); + }; +} diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..9daadcf6 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -266,7 +266,7 @@ r = await req(`/api/orgs/${orgAId}/export`, { headers: oauth }); assert.equal(r.status, 403, 'non-owner export → 403'); // ---- Webhooks ---- -r = await req(`/api/orgs/${orgAId}/webhooks`, { method: 'POST', headers: auth, body: body({ url: 'http://127.0.0.1:9/hook', events: ['project.update'] }) }); +r = await req(`/api/orgs/${orgAId}/webhooks`, { method: 'POST', headers: auth, body: body({ url: 'https://example.com/hook', events: ['project.update'] }) }); assert.equal(r.status, 200, 'create webhook'); const wh = await r.json(); assert.ok(wh.secret.startsWith('whsec_'), 'webhook secret returned once'); diff --git a/tests/api/webhook-ssrf.test.mjs b/tests/api/webhook-ssrf.test.mjs new file mode 100644 index 00000000..945d2b53 --- /dev/null +++ b/tests/api/webhook-ssrf.test.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert'; +import { MockAgent } from 'undici'; +import { + createSafeWebhookLookup, + isPublicWebhookIp, + isSafeWebhookUrl, +} from '../../server/webhook_destination.mjs'; + +for (const address of [ + '0.0.0.0', + '10.0.0.1', + '100.64.0.1', + '127.0.0.1', + '169.254.169.254', + '172.16.0.1', + '192.168.0.1', + '198.18.0.1', + '224.0.0.1', + '240.0.0.1', + '::', + '::1', + '::127.0.0.1', + '::ffff:127.0.0.1', + '64:ff9b::a00:1', + '64:ff9b::7f00:1', + '64:ff9b:1::1', + '2001:db8::1', + '2002:7f00:1::', + 'fc00::1', + 'fe80::1', + 'ff00::1', +]) { + assert.equal(isPublicWebhookIp(address), false, `${address} must not be a webhook destination`); +} +for (const address of [ + '1.1.1.1', + '8.8.8.8', + '64:ff9b::808:808', + '2001:4860:4860::8888', + '2606:4700:4700::1111', +]) { + assert.equal(isPublicWebhookIp(address), true, `${address} remains a public webhook destination`); +} + +for (const url of [ + 'http://example.com/hook', + 'https://localhost/hook', + 'https://service.local/hook', + 'https://127.0.0.1/hook', + 'https://100.64.0.1/hook', + 'https://198.18.0.1/hook', + 'https://[::127.0.0.1]/hook', + 'https://[::ffff:127.0.0.1]/hook', + 'https://[64:ff9b::a00:1]/hook', + 'https://[64:ff9b::7f00:1]/hook', + 'https://[64:ff9b:1::1]/hook', + 'https://user:secret@example.com/hook', +]) { + assert.equal(isSafeWebhookUrl(url), false, `${url} must fail closed before persistence or delivery`); +} +assert.equal(isSafeWebhookUrl('https://example.com/hook'), true, 'public HTTPS hostname remains admissible'); +assert.equal( + isSafeWebhookUrl('https://[64:ff9b::808:808]/hook'), + true, + 'RFC 6052 WKP remains admissible only when its embedded IPv4 destination is public', +); + +function runLookup(lookup, hostname = 'webhook.example.test', options = {}) { + return new Promise((resolve, reject) => { + lookup(hostname, options, (error, address, family) => { + if (error) reject(error); + else resolve({ address, family }); + }); + }); +} + +const privateOnlyLookup = createSafeWebhookLookup((_hostname, options, callback) => { + assert.equal(options.all, true, 'guarded lookup inspects every resolved address'); + assert.equal(options.family, 0, 'guarded lookup requests both address families'); + callback(null, [{ address: '127.0.0.1', family: 4 }]); +}); +await assert.rejects( + runLookup(privateOnlyLookup), + /SSRF blocked/, + 'a private-only DNS answer must fail before socket connection', +); + +const mixedLookup = createSafeWebhookLookup((_hostname, _options, callback) => { + callback(null, [ + { address: '93.184.216.34', family: 4 }, + { address: '169.254.169.254', family: 4 }, + ]); +}); +await assert.rejects( + runLookup(mixedLookup), + /SSRF blocked/, + 'one non-public A or AAAA answer must reject the hostname instead of racing the public answer', +); + +const publicLookup = createSafeWebhookLookup((_hostname, _options, callback) => { + callback(null, [ + { address: '93.184.216.34', family: 4 }, + { address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 }, + ]); +}); +assert.deepEqual( + await runLookup(publicLookup), + { address: '93.184.216.34', family: 4 }, + 'socket lookup must return the exact admitted address rather than resolving the hostname again', +); + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +const { app, safeWebhookAgent } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const req = (path, opts = {}) => + app.request(path, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, + }); +const body = (value) => JSON.stringify(value); + +let response = await req('/api/auth/signup', { + method: 'POST', + body: body({ email: 'ssrf-owner@example.test', password: 'password123', name: 'SSRF owner' }), +}); +assert.equal(response.status, 200, 'signup succeeds'); +const { token } = await response.json(); +const auth = { authorization: `Bearer ${token}` }; + +response = await req('/api/me', { headers: auth }); +assert.equal(response.status, 200, 'owner workspace is available'); +const orgId = (await response.json()).orgs[0].id; + +for (const url of [ + 'https://[fc00::1]/hook', + 'https://[fe80::1]/hook', + 'https://[::ffff:127.0.0.1]/hook', + 'https://100.64.0.1/hook', + 'https://198.18.0.1/hook', + 'http://169.254.169.254/hook', + 'http://example.com/hook', +]) { + response = await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', + headers: auth, + body: body({ url, events: ['project.update'] }), + }); + assert.equal(response.status, 400, `${url} must fail closed at webhook registration`); +} + +response = await req(`/api/orgs/${orgId}/webhooks`, { + method: 'POST', + headers: auth, + body: body({ url: 'https://example.com/hook', events: ['project.update'] }), +}); +assert.equal(response.status, 200, 'public HTTPS webhook registration remains available'); +const webhookId = (await response.json()).id; + +response = await req('/api/projects', { + method: 'POST', + headers: auth, + body: body({ name: 'Webhook delivery boundary', orgId }), +}); +assert.equal(response.status, 200, 'project fixture is created'); +const project = await response.json(); +let projectVersion = project.version; + +db.prepare('UPDATE webhooks SET url = ? WHERE id = ?') + .run('https://webhook.example.test/start', webhookId); +db.prepare('DELETE FROM webhook_deliveries WHERE webhook_id = ?').run(webhookId); + +const redirectAgent = new MockAgent(); +redirectAgent.disableNetConnect(); +redirectAgent + .get('https://webhook.example.test') + .intercept({ path: '/start', method: 'POST' }) + .reply(302, '', { headers: { location: 'https://169.254.169.254/internal' } }) + .times(2); +redirectAgent + .get('https://169.254.169.254') + .intercept({ path: '/internal', method: 'POST' }) + .reply(204, ''); + +const originalSafeAgentDispatch = safeWebhookAgent.dispatch; +let webhookDispatches = 0; +safeWebhookAgent.dispatch = function dispatchThroughRedirectFixture(options, handler) { + webhookDispatches += 1; + return redirectAgent.dispatch(options, handler); +}; +try { + response = await req(`/api/projects/${project.id}`, { + method: 'PUT', + headers: auth, + body: body({ version: projectVersion, name: 'Webhook redirect boundary', tasks: [] }), + }); + assert.equal(response.status, 200, 'project update succeeds independently of rejected webhook redirects'); + projectVersion = (await response.json()).version; + await new Promise((resolve) => setTimeout(resolve, 1200)); + + const deliveries = db.prepare( + 'SELECT status_code AS statusCode, ok, attempt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id', + ).all(webhookId); + assert.equal(webhookDispatches, 2, 'a rejected redirect is attempted once and retried exactly once'); + assert.deepEqual( + deliveries.map(({ statusCode, ok, attempt }) => ({ statusCode, ok, attempt })), + [ + { statusCode: null, ok: 0, attempt: 1 }, + { statusCode: null, ok: 0, attempt: 2 }, + ], + 'redirect rejection preserves the delivery receipt and one-retry contract', + ); + const pendingRedirects = redirectAgent.pendingInterceptors(); + assert.equal(pendingRedirects.length, 1, 'only the private redirect target remains unrequested'); + assert.equal(pendingRedirects[0].origin, 'https://169.254.169.254'); + assert.equal(pendingRedirects[0].path, '/internal'); +} finally { + safeWebhookAgent.dispatch = originalSafeAgentDispatch; + await redirectAgent.close(); +} + +db.prepare('UPDATE webhooks SET url = ? WHERE id = ?') + .run('https://127.0.0.1:9/internal', webhookId); +let blockedDispatches = 0; +safeWebhookAgent.dispatch = function failIfBlockedDestinationReachesTransport() { + blockedDispatches += 1; + throw new Error('blocked webhook destination reached network transport'); +}; +try { + response = await req(`/api/projects/${project.id}`, { + method: 'PUT', + headers: auth, + body: body({ version: projectVersion, name: 'Webhook delivery boundary', tasks: [] }), + }); + assert.equal(response.status, 200, 'project update succeeds independently of webhook delivery'); + assert.equal(blockedDispatches, 0, 'persisted non-public IP literals are refused before network dispatch'); +} finally { + safeWebhookAgent.dispatch = originalSafeAgentDispatch; +} + +console.log('✓ webhook SSRF registration, DNS admission, redirect, and delivery-boundary regression tests passed'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..ba29342e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,16 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/webhook_destination\.mjs/, + 'the webhook outbound-policy boundary is instrumented', +); +assert.match( + scripts['test:api'], + /tests\/api\/webhook-ssrf\.test\.mjs/, + 'the webhook destination regression executes in the API suite', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/,