Skip to content

Security

Last updated: 2026-08-23 — ADR-16 changed @finstack/ops-mcp’s D1 trust boundary (see Trust Boundaries): 25 logical database names now resolve to only 10 physical databases, so a name no longer implies an isolated database. Prior: 2026-07-29 — new @finstack/ops-mcp operator D1-access surface (local stdio, not deployed — see Trust Boundaries). Prior: 2026-07-25 — ADR-9: finstack-rs (Rust/Postgres) deleted; rewritten to describe finstack’s actual current mechanisms, verified against source rather than carried over from the deleted Rust implementation. Where a mechanism could not be confirmed present, it’s marked as such rather than assumed.

API callers: argon2id-hashed sk_* keys, verified via svc-auth/src/api-keys.ts (argon2id WASM module, statically-precompiled for the Workers bundler — hash-wasm’s runtime-compile approach is incompatible with the Workers sandbox). Comparison via timingSafeEqual.

MFA (TOTP): mounted at /mfa/* in svc-auth (RFC 6238-style, 30s window, 5-min single-use challenge tokens — svc-auth/src/mfa.ts). No evidence of a step-up gate on high-value routes (the Rust backend’s require_mfa_token middleware pattern, forcing X-MFA-Token on routes like API-key revoke, was not found anywhere in finstack on grep) — MFA currently exists as a standalone enroll/verify flow, not yet wired as a gate in front of other Workers’ routes.

OAuth: mounted at /oauth/* in svc-auth.

Dashboard users — broken. svc-auth has passkey ceremony logic (ceremonies.ts) but it’s never mounted in index.ts — no /passkeys/* route exists. Dashboard login has no working auth path.

Inter-Worker trust: every domain Worker (payment, payout, etc.) authenticates callers via a signed assertion from svc-auth, verified with the shared INTERNAL_ASSERTION_SECRET (verifyAssertion()) — routes derive tenantId solely from the verified assertion’s claims, never from a caller-supplied header. Confirmed via payment/src/index.ts’s own comment: “trusts a caller-supplied tenantId — every route derives it solely from the signed assertion.”

Constant-time compares: timingSafeEqual used in svc-auth for API key and MFA token comparisons.

No database-level RLS — D1/SQLite has no equivalent to Postgres Row-Level Security. Isolation is entirely assertion-based: svc-auth issues the signed assertion carrying tenantId; each Worker scopes its D1 queries via TenantScopedDb(env.DB, tenantId) (seen in payment/src/index.ts and presumably every other domain Worker — not individually re-verified per Worker here). This is a materially different isolation model than the deleted Rust backend’s Postgres RLS, not a like-for-like port — a bug in a single Worker’s TenantScopedDb usage has no database-level backstop the way a missing WHERE tenant_id = $1 did under RLS.

  • No CF Worker gateway proxies to an internal-only backend anymore — gateway dispatches directly to domain Workers via CF service bindings (no network hop, no bearer-token-over-HTTP step to secure).
  • No /admin/* HTTP surface exists. It only ever existed via the now-deleted finstack-admin-gateway → Rust api-admin — see CLAUDE.md’s functional-gaps list.
  • Operator-privileged D1 access exists, but as a local process, not an HTTP surface (2026-07-29). @finstack/ops-mcp (packages/finstack-ops-mcp, see COMPONENTS.md/INTERFACES.md) reads across 25 logical FinStack database names via the Cloudflare D1 HTTP API — as of 2026-08-23 (ADR-16), those 25 names resolve to only 10 physical databases, since 15 non-critical Workers share a critical Worker’s database by theme. This narrows the isolation a database name implies, not just its count: a query scoped by a logical name (e.g. tax-db) can read any table in the physical database it shares (e.g. payment-db’s payments table) — ops_db_query validates the database name, never which tables belong to which Worker. Schema-introspection queries (SELECT name FROM sqlite_master, PRAGMA table_list) against any of the 15 aliased names likewise surface every co-located Worker’s tables, not just the named one’s. Every individual table stays collision-free (verified at consolidation time, see DECISIONS.md’s ADR-16), so this widens what one query can see, not what it can silently corrupt across Workers — but it is a real narrowing of the isolation this tool previously had, and the same privilege-boundary reasoning below (possession of CF_API_TOKEN) is what actually bounds it, same as before ADR-16. Deliberately not a deployed Worker: it runs only as a local stdio process launched by Claude Code. CF_API_TOKEN is a static value in ~/.claude.json’s MCP server config (the vault is where it was generated/is recorded, not a runtime fetch source), never committed to this repo — so the process has no internet-reachable listener at all: no hostname to secure, no WAF/allowlist to maintain, and no repeat of the finstack-admin-gateway incident class (an unauthenticated, auto-token-injecting admin Worker with no IP allowlist, deleted 2026-07-26 — see BUGS.md). Read-only by construction (ops_db_query rejects anything but a single SELECT/PRAGMA before ever calling the D1 API) and redacts columns matching secret/credential/password/hash (substring) or code/evidence (exact name only — narrowed after over-redacting real columns like payouts.return_code, see db.ts) in every returned row, mitigating (not closing — see below) the same bulk-exposure class ADR-12’s Task 6 review caught in compliance’s list endpoints. Scope is deliberate and limited, and bypassable by design, not by oversight: this is a key-name filter over the returned row shape, not a query-plan analysis. Aliasing a sensitive column to an innocuous name (with or without AS, or wrapped in an expression like substr()) defeats it, and a WHERE-clause predicate can exfiltrate a value blindly without it ever appearing in the response at all — no output-side filter can stop either. An AS-aliasing regex guard was tried and removed (PR #516’s second review round: it caught only the bare-column form while both false-negativing on expression-wrapped columns and false-positiving on ordinary table aliases like FROM x AS t). The real privilege boundary here is possession of CF_API_TOKEN itself — whoever holds it already has unmediated D1 access outside this tool (a raw curl to the same D1 HTTP endpoint bypasses redaction completely), so this filter’s actual job is catching accidental exposure (an operator or LLM doing SELECT * without noticing a secret column), not defending against deliberate extraction by someone who already holds the credential. Scope is also limited to credential/secret-class columns, not general PII (email/phone/address are left visible — routine for most diagnostic queries). See DECISIONS.md’s ADR-13 and INTERFACES.md for the full list and rationale.
  • Stripe webhooks: HMAC-SHA256 validated directly by the payment Worker’s webhook-receiver.ts (verifyWebhookSignature) — no separate IP-allowlisting gateway layer exists anymore (the old finstack-webhooks-gateway’s KV-based Stripe IP allowlist was deleted along with it).
  • AI Worker (ai) — task input crosses the trust boundary to Neureus: task input sent to the ai Worker is transmitted to Neureus (and downstream OpenAI/Anthropic/Workers AI depending on model). No redaction/classification of input data; tenants are responsible for not including regulated PII in task inputs. (Carried over unchanged from the deleted Rust implementation — same integration, same caveat.)
  • Moov is not integrated into finstack at all (deferred scope — see docs/cf-rewrite-deferred-scope.md). The prior Moov webhook-signing trust-boundary notes (signed metadata, not body) described the deleted Rust implementation and don’t apply to any currently-running code.

No rate limiting found in gateway or svc-auth on grep (rate.limit/RateLimit — no matches). Same gap as the deleted Rust backend had (FinstackError::RateLimited type defined, no layer wired) — not a regression introduced by the removal, just not yet re-verified as fixed either.

Edge layer: CF WAF DDoS protection active on the finstack.sh zone (Galactic account). The Rust-era pending WAF rate-limit rule for /api/passkeys/* is moot — that route doesn’t exist on the CF side yet.

Set by the gateway Worker (workers/gateway/src/index.ts): X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin. Dashboard (app.finstack.sh) headers via finstack-dashboard/public/_headers — not re-verified in this pass.

  • routing’s processor_configs.credentials (payment/payout processor API keys, e.g. Stripe secret keys) is AES-256-GCM encrypted (workers/routing/src/crypto.ts), keyed by the ROUTING_CREDENTIALS_MASTER_KEY Worker secret. Previously stored as plaintext JSON — deliberately deferred (per the migration’s own comment) until a real processor adapter existed to decrypt it; ADR-12’s Stripe Connect payout rail is that adapter. routing is the only Worker that ever decrypts a credential; every other Worker only ever sees the metadata-only view (toView() excludes credentials from every response, unchanged by this).
  • recipient’s recipient_bank_accounts.account_number_enc is AES-256-GCM encrypted the same way (workers/recipient/src/crypto.ts), keyed by RECIPIENT_BANK_MASTER_KEY — write-only today (no decrypt path exists, matches the pre-CF-rewrite Rust behavior).
  • All secrets are Cloudflare Worker secrets (wrangler secret put), set per-Worker — no central secret store. Fly.io secrets no longer apply (Fly infra untouched but no longer deployed to from this repo).
  • Never committed to git — .cf-token, .env, *.key, *.pem in .gitignore
  • VENDORS.md, ENVIRONMENTS.md, SECURITY.md reference env var names only, never values
  • Request bodies: parsed via each Worker’s own JSON parsing + validation (no shared framework equivalent to Rust’s axum::Json<T> + serde deny_unknown_fields was verified in this pass — validate per-Worker, don’t assume a repo-wide pattern).
  • SQL: D1 queries should be parameterized (env.DB.prepare(...).bind(...)) — not exhaustively re-audited across all ~30 Workers in this pass.
  • database Worker (Post AI proxy): deferred, not live (POST_AI_API_KEY unvaulted) — the Rust backend’s validate_path_segment() allowlist pattern should be re-verified as ported before this Worker goes live, not assumed.

PCI scope: FinStack is a payment platform. Stripe handles card data (PCI SAQ A). FinStack stores payment intent IDs and amounts only — no PANs, CVVs, or card holder data.

Identity PII in compliance_kyc_checks.evidence (ADR-12 Task 6, 2026-07-28): on a Stripe Identity verified webhook, compliance persists the recipient’s verified name/DOB/address into evidence.verifiedOutputs — plaintext D1, not encrypted at rest (unlike routing’s processor credentials or recipient’s bank account numbers above). SSN/passport numbers are deliberately not mapped or stored. Single-record reads (GET /compliance/kyc/:id, and the response from /review//decide) include it; the two list-shaped reads (GET /compliance/kyc/history, GET /compliance/kyc/stale) redact it — both would otherwise return verified PII for every matching row in the tenant in one call, gated only on the general compliance:read scope. Revisit if a use case needs verifiedOutputs in a list response — that would need a narrower scope, not just re-adding the field.

OFAC/sanctions screening — compliance’s own screening is a stubbed no-op; the money-moving processors screen instead (clarified 2026-07-28, user). compliance’s in-house sanctions-screening path (the Rust SanctionsScreener over the Treasury SDN list) was never ported to finstack and is a documented, intentional no-op (workers/compliance/src/index.ts’s NOTE comment; docs/cf-rewrite-deferred-scope.md). That’s a real gap in FinStack’s own independent screening — but it is not the same as “no screening happens anywhere in the money-movement path.” Every rail that actually moves real funds routes through a regulated processor with its own sanctions-compliance obligations:

  • Recipient/payout rail (ADR-12, Stripe Connect Custom): Custom Connect account creation requires the platform to submit identity/verification data via API (POST /v1/accounts, POST /v1/accounts/{id}/persons) — Stripe underwrites and screens that submitted data, including against sanctions lists, as part of its own account-approval process before enabling payouts capability. A recipient who fails Stripe’s own screening does not get a functioning Connect account, independent of anything compliance does or doesn’t check.
  • Wallet topup / checkout charges (Stripe PaymentIntent): processed through Stripe’s own card-network and Radar compliance systems, which screen cardholders/counterparties as part of Stripe’s standard payment-processing compliance obligations.
  • Moov (not yet integrated — zero rows in processor_configs — but named in ADR-12 as the second rail): Moov’s own KYC/AML onboarding for ACH/RTP counterparties includes sanctions screening as part of its compliance program, same pattern.

What this does and doesn’t close: it substantially reduces (not eliminates) the exposure the “hard blocker” framing in DECISIONS.md’s ADR-12 and the original docs/cf-rewrite-deferred-scope.md entry assumed, since neither accounted for processor-side screening. It does not mean FinStack has zero remaining exposure or that this repo’s own compliance posture is fully settled — whether relying on a processor’s screening (rather than running an independent, redundant check) satisfies FinStack’s own regulatory obligations as the platform sitting between the tenant and the processor is a policy question for whoever owns FinStack’s regulatory posture, not something this correction settles unilaterally. Treat compliance’s stub as a known gap in defense-in-depth, not an open door.

Tap2 Wallet launch implication: given the above, Tap2 Wallet’s launch (docs/superpowers/plans/2026-07-28-tap2-wallet-production-launch.md) is not relying on zero screening — its wallet-topup path is Stripe-charge-screened today, and its seller-payout path (gated on ADR-12 Task 7) will be Stripe-Connect-screened once live. No monitored-launch ceiling is being tracked for this specific item as a result; if whoever owns regulatory posture wants FinStack to run its own independent screening in addition to relying on processor screening, that’s a forward-looking build decision (see docs/cf-rewrite-deferred-scope.md’s OFAC row), not a pre-launch blocker.

What “real screening” would close this via: a sanctions-list API integration gating compliance’s KYC approval, or gating recipient/payout creation, for the affected subject. Not built as part of this launch.