Add SSRF protection for webhook URLs - #48
Conversation
…exec, dependency bumps Webhooks: - Validate subscription URLs at create/update (https-only, no private or internal hosts in deployed envs; relaxed in local/test for DX) - Re-check at delivery time, resolving DNS and rejecting URLs whose answers include private addresses (sendWebhook) - Include unix-seconds timestamp inside the signed payload so receivers can reject replayed deliveries; documented on the info endpoint Init tooling: - Switch infisical CLI calls from shell-string execAsync to execFileAsync so secret values containing quotes/$/backticks can't inject or corrupt (infisicalSetup, infisical api fallback, cloudflarePagesSetup) - system.mock routes execFileAsync through the existing exec stub matching Dependencies: - hono 4.12.15 -> 4.12.25 (cache cross-user leakage, bodyLimit bypass, Set-Cookie injection advisories, et al) - @opentelemetry sdk-node/auto-instrumentations/otlp exporters bumped (prometheus exporter crash advisory) - resolutions: protobufjs 7.6.3, shell-quote 1.8.4 - bun audit: 15 findings -> 1 (elliptic low, no fixed release upstream) https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Additive only; no prior content removed. Captures: - ACCEPTED: File ownership via registry-enforced false polymorphism (ownerModel + User/Org/Space FKs, Contact pattern); uploadedBy stays as provenance - OPEN: S3 key strategy — path-mirrored vs immutable owner-prefixed id keys, with full tradeoff + orphan-class analysis - RECOMMENDED: per-purpose contentType/size validation promoted to Phase 1 via a shared file kind registry - NEW: transfer grant-disposition semantics (maintain = live sync vs sever = lazy-copy snapshot; receiving owner chooses, sever default) - Misc: BigInt size, storage adapter already built, rebac delegation note, expiresAt for ephemeral kinds https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
subscribe accepts any channel from any (anonymous) connection with no authz check. Payloads are refresh triggers only (invalidateQueries), so the exposure is a side-channel — change-activity/existence oracle by guessable channel key — not a data leak. Documented evidence, accurate severity, and the fix: subscribe must require identity + run the underlying route's read permission check, re-authorize on identity change, and add rejection tests. Flagged the stale "infra complete" section. Mirrored a pointer into TODO.md Security. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
…quisite The check is ~5 lines mirroring validatePermission; the only real work is building the user's permix on the connection at authenticate (reuse setUserContext logic). Replaces the over-scoped 4-bullet checklist. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Evidence-backed status corrections (read from code, not labels): - COMM-001: "Not Started" -> In Progress; MJML+Resend+Bouncer send engine is built end to end (sendEmail job), original React Email/SES design superseded. Original intent retained. - FEAT-005: -> Complete (schema/hooks/retention/API shipped; UI in FEAT-017). - FEAT-013: note actual coverage (1/23 models) and that rotation is blocked on CICD, so the rotation integration test is deferred not owed. - FEATURES.md: 19 -> 23 models; event-broadcasting and email job-queue marked wired (they are); email pipeline "actively used" not "needs wiring". - TODO.md: @aws-sdk installed; WS event handlers wired (subscribe authz still pending, cross-ref Security section). Not done here (surfaced for owner decision): duplicate ticket IDs (INFRA-009/011/012), FEAT-005 archive path, WebhookSubscription registry gap. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
- Delete INFRA-011-typed-prisma (identical dup of INFRA-012 save the title; no inbound refs). typed-prisma is now solely INFRA-012. - Railway buckets keeps INFRA-011 (collision resolved). - Renumber audit-logs-cold-storage INFRA-009 -> INFRA-013 (adapter-primitives keeps INFRA-009; it's the actively-referenced dependency). Update heading + all inbound refs (kanban-backlog, FEAT-017). Each ticket ID is now unique. Left as-is (surfaced separately): FEAT-005-*-webhook-migration is a sub-doc not a collision; INIT_SCRIPT.md's "create INFRA-009 for space scaffolding" prose is a stale future-work note. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
…map) FEATURES.md corrections from the territory scorecard: - Permify -> Permix (the actual library) ×2 - webhook signing: HMAC-SHA256/shared-secret -> RSA-SHA256 asymmetric with public-key verification + replay timestamp - Statistics: 19 -> 23 models, 93 -> ~172 test files, 70+ -> 90 endpoints - factory "100% model coverage" -> 20/23 (auth/log models excluded) - Audit Logs -> Complete (retention job + admin API shipped; UI in FEAT-017) - "No any" -> "any avoided in app code" (rare boundary casts exist) - OAuth social: Google wired; Microsoft/GitHub scaffolded not configured New ticket DEV-003: file-header structured-comment annotations (@feature/@primitive/@status/@ticket/@doc) as single source for a generated MAP.md + a CI honesty rule that prevents map/territory drift. Recommends comments-not-decorators and a trimmed taxonomy. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Section header "93 Test Files" -> ~172; mock-receiver "HMAC signatures" -> RSA-SHA256. Completes the scorecard reconciliation. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Rewrites DEV-003 to the converged design (seams for exploration): - 3 core file tags: @kind / @partof / @uses (+ @constructs for factories) - class:name seam syntax, validated against a typed seam registry - status/doc/ticket live on the seam, not the file - enforce presence on all non-test/non-barrel files; auto-fill the path-derivable class, overload to override, explicit wins - open: @uses presence-vs-reconciliation; @uses scope Adds scripts/seams/{kinds.ts,registry.ts} as the controlled vocabulary (starter set, grounded in the whole-codebase reads). Not yet applied to source — validating tagging judgment on a sample first. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Multi-line block, one @tag per line — clean diffs, line-oriented parse, repeated tags (multiple @uses, primary+secondary @partof) each own line. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
- feature:organizations/spaces -> feature:tenancy (orgs+spaces+memberships) - primitive:rebac -> primitive:authz (auth + authz pairing) - drop fused @kind app-event-handler -> @kind handler + @partof seam (handler is a role that recurs across app-events, jobs, db hooks) - add @kind helper; registry/component/transformer from prior commit - @uses = deep association only (resolved); registry seam class; prisma (deep ORM) vs postgres (ambient) split https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
…lision - add component, page, hook (React), store (zustand slice) - hook = React hook (universal); db mutation-lifecycle hooks are @kind handler @partof primitive:mutation-lifecycle (per "handlers in the database") - restore utils, clean mangled comments from prior edit https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Corrects the framing: tag @uses X when X is integral to the file's contract, regardless of how common X is. json-rules (ubiquitous) is tagged where built-on; user hydration @uses caching (it's a cache wrapper, verified); incidental context queries are not "using the DB". https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
- @concern: closed cross-cutting properties (pii, security, secrets, money, tenant-isolation, retention, idempotency, hot-path, public-surface, audit) — applied liberally - meaning emerges from intersection of true edges, not one narrow tag; prefer multiple broad-true tags. Most files carry multiple @partOf/@uses; multi-@partof is the bridge/boundary case - discipline preserved: every edge must be true and load-bearing https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
@partof a, b, c on a single line (not repeated tags). Axes are the lines; values are a list. Parse: match tag, split(','). https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
- no kebab in any axis: routeTemplate, appEventHandler, tenantIsolation, hotPath, publicSurface; seam names camelCase (primitive:appEvents, registry:falsePolymorphism, feature:auditLogs, …) - uniform token convention across all axes: camelCase value, lowercase class prefix, class:name colon separator - @kind is multi-valued like the others — a file can be `entrypoint, registry`; auto-fill stamps the path-derived primary, overload adds roles - @constructs appEventHandler https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
For shape-finding work (design/taxonomy/modeling/naming): interrogate before proposing, ask at forks instead of assuming, name assumptions and mark confidence, prefer options over a single recommendation, and small-piece-then-validate. If you catch yourself assuming, stop — to assume makes an ass of u and me. Scoped to discovery, not execution. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
This codebase deliberately builds many things YAGNI would defer (primitives/seams/foundations ahead of a consumer). A silent skip deletes a decision the author wanted and they never see it. Treat a YAGNI-flagged item as a fork (per 0.3): name it, say why it looks adjacent, ask build-now-or-defer. Tiebreaker softened to leanings. https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
Overbuild (scope invention) and underbuild (trimming real completion) are equal failure modes; both feel virtuous from inside (trimming feels disciplined, adding feels thorough). The danger is the silent call in either direction. Discriminator: clear in-scope build, clear noise skip, genuine judgment call EITHER way -> flag and ask. Don't silently add on a guess of "thorough" or silently trim on a guess of "needed". https://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV
DEV-003 design decisions (brainstorm session)Working through DEV-003 as a standalone, Biome-shaped tool. Locking the decisions below; open items listed at the bottom. Naming
Packaging
Registry change
|
Rewrites DEV-003 around the converged "atlas" tool: - Name: atlas (package + @atlas block tag + .atlas/ folder + CLI). Map vs territory framing; seams = the nodes the atlas charts. - Standalone Biome-shaped tool; template is consumer #1. .atlas/ holds config/kinds/concerns/seams; atlas ships default kinds+concerns, repo owns seams. - CLI: atlas graph | check | coverage | generate. - Registry is STRUCTURE ONLY: docs[], tickets[], modules[], integrations[], packages[]. status/note/ambient all dropped (status not even derived). - modules+integrations are the same mechanism either side of the app boundary; N:M membership, purely to fill @partof (multi-@partof normal, no collision). - invert(field) export for free reverse lookup. - Auto-stamp = composing glob rules with inline positional captures ($1 captures, */** discard). @kind structural; @partof via membership lookup; explicit wins. - @uses never auto-inserted — absence = uncurated, explicit `@uses none` = curated-empty; coverage distinguishes. - Enforcement is existence + vocab-validity only — no import reconciliation, no "is partOf true", no derived status. - @kind additions: constant, type. Open: environment as a high-level tag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The vocabulary + registry (kinds/concerns/registry) belong in the standalone atlas repo (git@github.com:inixiative/atlas.git), not in template/scripts. Remove the prototype files from here; record the repo home in the ticket. A consuming repo only keeps its own .atlas/ config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…overwrite) Blanks are fillable on demand, not permanent. Adds the `atlas stamp` patcher: - targeting: all / folder / individual / --axis - --dry-run: preview + the review surface (acceptance promotes a proposal to a curated tag, so untouched files stay uncurated — preserves absence-as-signal) - additive (default): fill only absent; only mode that touches @uses, as proposals - overwriting (--overwrite): force-resync derivable axes (@kind/@partof) only; never the curated axes (@uses/@concern); dry-run diff is the safety net - @uses fill = import-graph proposes -> agent judges load-bearing -> human accepts Separates `atlas stamp` (write tags) from `atlas generate` (-> MAP.md) in the CLI; threads the patcher through Rollout phases 2-3 and the decisions log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ty-review-o5gea3 # Conflicts: # apps/api/package.json # bun.lock
…into atomic files Timestamp: - standardize the signed webhook body `timestamp` on ISO-8601 (`new Date().toISOString()`), matching the rest of the event system (appEvents/emit, hooks/webhooks/hook); no date lib. - carry the event time through: the hook stamps it once and enqueues it; sendWebhook signs that value instead of re-stamping (was: compute ISO at the hook → drop → recompute unix at send). `SendWebhookPayload.timestamp` is now required; the test asserts true passthrough. - update the webhookSubscriptionInfo doc (unix-seconds → ISO-8601). Validators: - split lib/webhooks/validateWebhookUrl.ts into atomic files under lib/webhooks/validators/ (isEnforcedEnvironment, hostnameOf, isPrivateAddress, validateWebhookUrl, resolveWebhookUrlBlockReason), each with a co-located test — matching the lib/prisma & lib/utils style. Repoint the schema + sendWebhook importers. Verified: apps/api typecheck + biome clean; webhook validator/delivery/CRUD tests 37/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at zod + write hook Threat-modeled the webhook SSRF concern (blind fire-and-forget POST, response not surfaced to tenant) and trimmed the over-built version down to a proportionate guard: - validateWebhookUrl: sync, throws makeError(400) — parseable URL, http/https, https-in-enforced, no localhost, no literal private/internal IP. No DNS. - zod refine wraps it (try → ctx.addIssue) for a clean 400 at create/update. - new WebhookSubscription before-create/update mutation hook runs the same sync check on every write path (defense-in-depth, can't be skipped by a new route). - isEnforcedEnvironment: drop the process.env fallback dance; local relaxed, test + pr/staging/prod enforce; `?? true` keeps it fail-CLOSED (was a fail-open regression). test enforces so rules run under prod posture. - sendWebhook: reverted to plain fetch (dropped the async DNS/rebinding check — disproportionate for a blind POST and defeated by rebinding anyway); kept the ISO-8601 event-time timestamp passthrough. - removed the async assertWebhookUrlResolvable + its test. Verified: apps/api typecheck + biome clean; webhook validator/delivery/CRUD tests 31/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…F rejection test - hooks/index.ts: fix biome import-sort (registerWebhookSubscriptionUrlHook before registerWebhookHook) — the prior commit shipped this red. - webhookSubscriptionCrud.test.ts: PATCH with a private URL (https://10.0.0.1) → 400, proving the zod SSRF guard end-to-end through a real route (not just unit-level). Verified: full `bun run check` green — lint, monorepo typecheck, all backend + frontend tests, CI rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After merging main (#47/#48/#49) into the atlas branch: - atlas stamp --write: re-derived @kind/@partof on the 41 resolvable files whose blocks were dropped by the merge (took main's code on conflicts) or are new (prismaMapRelations, contact defs, etc.). - hand-authored @atlas blocks for the 5 new webhook files that auto-stamp couldn't resolve (lib/webhooks/validators/* + hooks/webhookSubscriptionUrl) → all map to the existing feature:webhooks concept (kind: validator/helper/handler; @uses primitive:errors / infrastructure:{env,prisma} where load-bearing). - regenerated MAP.md. atlas check passes on the new paths; api + db typecheck + biome clean. Note: feature:webhooks' stamp rule only covers modules/webhookSubscription/**, so lib/webhooks + the hook needed hand-blocks — worth a .atlas/config.ts rule later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(atlas): add .atlas/ config (DEV-003 dogfood baseline) kinds (extends atlas defaults), concepts registry (feature/primitive/ infrastructure + superadmin classless tag), and stamp rules. atlas itself is resolved via a local node_modules link until it's published to npm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): expand .atlas rules + concepts (packages, FE, superadmin, api primitives) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(ai): add atlas section — navigate by concept + how to write @atlas tags Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): wire routeTemplates/auth/redis + registry/client/schema/type rules From the tag-audit subagent: route-template helpers (makeController) → routeTemplates, auth machinery (lib/auth.ts + middleware/auth) → feature:auth (was untagged), db redis/lock → infrastructure:redis; add registry/client(broadened)/bare-schema/type kind rules. Frontend now stamped too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): annotate the codebase with @atlas blocks + MAP.md Auto-stamped @kind/@partof across apps + packages (93% coverage) and hand-curated @uses for feature:users/auth/email via agent passes. Generates MAP.md (the concept map). This is the dogfood of DEV-003 / @inixiative/atlas on the template. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): curate @uses + fill @kind for tenancy/contact/customer/webhooks/auditLogs, api primitives, packages Cycle 2 of agent tagging: ~322 more files completed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): docs→concept map + bridge/definition/error/generator rules Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): tag api lib/middleware/config + map frontend pages to features Cycle 3: ~40 backend infra files tagged, 51 frontend pages mapped to their feature. Remaining gaps need new-concept decisions (flagged for review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): add api primitives + infra + integration: class concepts Approved concepts: primitive:{requestContext,errors,messaging,lifecycle}, infrastructure:{storage,observability,env}, and the integration: class (integration:stripe, integration:resend). Wired rules + re-stamped @partof. missing @partof 80→53 (remainder is FE app-shells, intentionally concept-less). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): curate @uses for packages (ui/shared/db/sdk) + frontend pages Cycle 4: ~329 more files @uses-curated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): curate @uses tail — api lib/middleware/modules, superadmin FE, seeds/factories Cycle 5: ~188 more files @uses-curated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): re-curate inquiry @uses (lost in a reset) + final stragglers; ignore test/tooling @uses now curated repo-wide except intentional app-shells. Ignore build tooling + test infra + *.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): re-stamp + annotate post-merge, regenerate MAP.md After merging main (#47/#48/#49) into the atlas branch: - atlas stamp --write: re-derived @kind/@partof on the 41 resolvable files whose blocks were dropped by the merge (took main's code on conflicts) or are new (prismaMapRelations, contact defs, etc.). - hand-authored @atlas blocks for the 5 new webhook files that auto-stamp couldn't resolve (lib/webhooks/validators/* + hooks/webhookSubscriptionUrl) → all map to the existing feature:webhooks concept (kind: validator/helper/handler; @uses primitive:errors / infrastructure:{env,prisma} where load-bearing). - regenerated MAP.md. atlas check passes on the new paths; api + db typecheck + biome clean. Note: feature:webhooks' stamp rule only covers modules/webhookSubscription/**, so lib/webhooks + the hook needed hand-blocks — worth a .atlas/config.ts rule later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): map lib/webhooks + webhook url hook to feature:webhooks The webhook SSRF code lives outside modules/webhookSubscription (in lib/webhooks + hooks/webhookSubscriptionUrl), so the module-folder rule never reached it. Add an explicit partOf override (mirrors lib/storage, lib/errors, lib/auth) so those files resolve to feature:webhooks via config, not just hand-authored blocks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): kill phantom module:* memberships (393 → 0 unresolved) - config: exclude apps/api/src/modules/admin/** from the feature-module capture (admin is a positional grouping, not a feature; needs @inixiative/atlas's new per-rule `exclude`). Drops the phantom `module:admin` on every admin file. (Also: the earlier blanket `apps/api/src/$1/**` catch-all was already replaced with explicit appEvents/jobs/ws rules — it had phantom-tagged modules/lib/hooks/…) - concepts: register feature:cronJob (admin/cronJob — own model + CRUD) and add 'job' to primitive:jobs (admin/job enqueue surface), resolving the last 15. - stamp --write applied the 15 now-resolved admin files; regenerated MAP.md. Result: 0 unresolved memberships (was 393). Remaining atlas-check presence gaps are #50's pre-existing ~7% coverage (hooks/, searchable, etc.) — untouched here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(atlas): depend on published @inixiative/atlas@^0.1.0 (drop local link) atlas 0.1.0 is published with the per-rule stamp `exclude` used by .atlas/config.ts, so #50 no longer needs the local link — atlas check/stamp now run in CI / for anyone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Implements Server-Side Request Forgery (SSRF) protection for webhook subscriptions by validating that webhook URLs do not target private or internal addresses. Validation occurs both at creation time (synchronous policy check) and at delivery time (with DNS resolution).
Changes
New validation module (
validateWebhookUrl.ts):validateWebhookUrl()— synchronous policy check for URL format, scheme, and literal private addresses; usable in Zod refinementsresolveWebhookUrlBlockReason()— delivery-time check that re-validates and resolves hostnames to detect DNS rebinding attacksisPrivateAddress()— classifies IPv4 and IPv6 addresses as private/reserved (loopback, RFC1918, link-local, multicast, etc.)local/test, enabled inpr/staging/prodWebhook schema validation (
webhookSubscriptionSchemas.ts):webhookUrlSchemaZod validator that rejects malformed URLs, non-HTTPS schemes (in enforced environments), and private addresses at creation timeWebhook delivery (
sendWebhook.ts):resolveWebhookUrlBlockReason()before fetching to block private-address URLs and DNS rebinding attemptstimestampfield to webhook payload (inside signed body) to allow receivers to reject replayed deliveries'error'in webhook eventsTest coverage (
validateWebhookUrl.test.ts):Infisical CLI calls (
infisicalSetup.ts,cloudflarePagesSetup.ts,init/api/infisical.ts):execAsync()(shell-based) toexecFileAsync()(direct execution) to prevent command injection when interpolating argumentsexecFileAsync()Dependencies:
0.215.0→0.218.0)4.12.15→4.12.25)7.5.6→7.6.3)shell-quoteresolution to prevent shell injectionImplementation Details
::ffff:192.168.1.1) and both dotted-decimal and hex-group representationserrorfield for audit/debugging purposeshttps://claude.ai/code/session_015LJyNryMCkev9M8hDoz8GV