Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c0908f8
feat: prevent SSRF by blocking internal IPs in webhooks
seonghobae Sep 5, 2026
d1bdf34
test(webhooks): reproduce IPv6 SSRF registration gaps
seonghobae Sep 5, 2026
030ad21
test(webhooks): run SSRF address-family regression
seonghobae Sep 5, 2026
f17a735
chore(security): keep local SSRF finding out of repository doctrine
seonghobae Sep 5, 2026
7d662f3
test(webhooks): separate IPv6 and HTTPS SSRF boundaries
seonghobae Sep 5, 2026
970ba19
feat: prevent SSRF by blocking internal IPs in webhooks using undici …
seonghobae Sep 5, 2026
4b23a19
repair(webhooks): restore deterministic SSRF RED boundary
seonghobae Sep 5, 2026
5440791
test(webhooks): prove delivery-time SSRF boundary
seonghobae Sep 5, 2026
e0b5032
🛡️ Sentinel: [HIGH] Fix Webhook SSRF
seonghobae Sep 6, 2026
3f97f5e
test(security): require HTTPS for public webhook targets
seonghobae Sep 6, 2026
9fbb674
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent
seonghobae Sep 6, 2026
68d3451
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (with moduleprel…
seonghobae Sep 6, 2026
9a50ace
repair(webhooks): remove unrelated modulepreload delta
seonghobae Sep 6, 2026
8f78fc6
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
c0f54cf
test(webhooks): specify connection-time SSRF admission
seonghobae Sep 6, 2026
c120df5
fix(webhooks): bind DNS admission to socket lookup
seonghobae Sep 6, 2026
de2840d
docs(product): establish ScopeWeave technical gap baseline
seonghobae Sep 6, 2026
f202b65
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
3392262
docs(gaps): preserve webhook security baseline after integration
seonghobae Sep 6, 2026
f454ad7
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
944af28
test(webhooks): prove redirects fail closed before second hop
seonghobae Sep 6, 2026
1d5dc26
test(webhooks): exercise redirect and retry on production transport
seonghobae Sep 6, 2026
02657b6
test(webhooks): reject deprecated IPv4-compatible IPv6 destinations
seonghobae Sep 6, 2026
0e1ba9f
fix(webhooks): block IPv4-compatible IPv6 destinations
seonghobae Sep 6, 2026
0ed756f
docs(webhooks): align product gap baseline with current transport bou…
seonghobae Sep 6, 2026
c23b89d
test(webhooks): preserve RFC 6052 public-prefix semantics
seonghobae Sep 6, 2026
972d8be
fix(webhooks): evaluate RFC 6052 WKP by embedded IPv4 policy
seonghobae Sep 6, 2026
ca8807b
test(coverage): require webhook destination instrumentation
seonghobae Sep 6, 2026
b5b8279
test(coverage): instrument webhook destination policy
seonghobae Sep 6, 2026
fa7889c
docs(security): align webhook translation policy with IANA
seonghobae Sep 6, 2026
ca9072f
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
cf5aa71
fix(webhooks): restore standards-correct translation admission
seonghobae Sep 6, 2026
29011ac
test(webhooks): retain RFC 6052 and isolated-agent contracts
seonghobae Sep 6, 2026
d3c1121
test(coverage): restore webhook policy coverage contract
seonghobae Sep 6, 2026
d3bd8dd
test(coverage): restore webhook destination instrumentation
seonghobae Sep 6, 2026
4424100
docs(security): preserve translation and coverage authority
seonghobae Sep 6, 2026
95ec648
fix(webhooks): isolate IPv4 and IPv6 blocklists
seonghobae Sep 6, 2026
601802b
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
6f74242
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
bd27a03
repair(webhooks): restore reviewed destination admission tree
seonghobae Sep 6, 2026
80ca4b8
🛡️ Sentinel: [HIGH] Fix Webhook SSRF via UndiciAgent (Strict DNS-leve…
seonghobae Sep 6, 2026
c3d1c20
fix(webhooks): restore reviewed destination-policy tree
seonghobae Sep 6, 2026
70f0942
🛡️ Sentinel: Fix SSRF vulnerability in webhook creation endpoint
seonghobae Sep 6, 2026
1082f94
🛡️ Sentinel: [HIGH] Server-Side Request Forgery (SSRF) in webhook cre…
seonghobae Sep 6, 2026
3bfcd94
security(webhooks): adopt verified #667 tree into successor
seonghobae Sep 6, 2026
3d5aaa4
test(webhooks): isolate smoke from public DNS
seonghobae Sep 6, 2026
2d1baeb
test(webhooks): require bounded DNS admission
seonghobae Sep 6, 2026
d67903b
test(webhooks): execute DNS timeout regression
seonghobae Sep 6, 2026
508c166
fix(webhooks): bound DNS admission time
seonghobae Sep 6, 2026
cfb1375
test(webhooks): cover DNS timeout boundary
seonghobae Sep 6, 2026
234c015
refactor(webhooks): keep DNS timeout coverage causal
seonghobae Sep 6, 2026
1a513d7
docs(gap): record bounded webhook DNS admission
seonghobae Sep 6, 2026
0c3a15f
🛡️ Sentinel: [HIGH] Server-Side Request Forgery (SSRF) in webhook cre…
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,8 @@
**Vulnerability:** The backend CSV export for audit logs neutralized `=`, `+`, `-`, and `@` but failed to neutralize `|` (pipe) characters, allowing potential DDE (Dynamic Data Exchange) injection if exported logs were opened in spreadsheet software.
**Learning:** Spreadsheet formula defenses must cover all command-style prefixes including `|` across all CSV export boundaries, both frontend and backend.
**Prevention:** Update the sanitization regex in the backend export function to `/^[=+\-@|]/` so that all potentially executable spreadsheet payloads are prefixed with a single quote.

## 2026-09-06 - Server-Side Request Forgery (SSRF) in webhook creation
**Vulnerability:** Found a lack of internal IP address blocking in the webhook creation URL validation, allowing users to make the server perform requests to internal services (SSRF) by providing URLs like `http://127.0.0.1`.
**Learning:** The URL constructor normalizes IP representations (e.g., `2130706433` -> `127.0.0.1`) and can be effectively combined with `net.isIP` and string/regex matching on the `.hostname` property to protect against bypassing the filter via obscure IP formats.
**Prevention:** Always parse webhook or user-provided URLs using the native `URL` constructor to normalize formats, and explicitly reject hostnames resolving to loopback (`127.0.0.0/8`, `::1`), private networks (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), and cloud metadata IP ranges (`169.254.0.0/16`) to prevent SSRF vulnerabilities.
78 changes: 78 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'self';" />
<title>ScopeWeave Planner</title>
<link rel="preload" href="styles.css" as="style" />
<link rel="modulepreload" href="cloud-sync.js" />
<link rel="modulepreload" href="analytics.js" />
<link rel="modulepreload" href="app.js" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="toast-state.css" />
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions server/app.mjs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading