Skip to content

feat: Mercury native webhook with HMAC signature verification#99

Merged
chitcommit merged 4 commits intomainfrom
feat/mercury-native-webhooks
Apr 24, 2026
Merged

feat: Mercury native webhook with HMAC signature verification#99
chitcommit merged 4 commits intomainfrom
feat/mercury-native-webhooks

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented Apr 20, 2026

Summary

  • Add tenant-scoped Mercury webhook endpoint (/api/webhooks/mercury/:tenantId) accepting Mercury's native JSON Merge Patch event format
  • HMAC-SHA256 signature verification via Mercury-Signature header (Web Crypto, Workers-compatible)
  • Graceful signature skip when MERCURY_WEBHOOK_SECRET not configured (allows Mercury verification ping during registration)
  • Auto-classify ingested transactions, auto-create accounts, KV + DB dedup
  • Legacy ChittyConnect-normalized path preserved at /api/webhooks/mercury

Test plan

  • Deployed and verified 200 response on test ping
  • Mercury webhook registered for ARIBIA LLC - MGMT tenant
  • MERCURY_WEBHOOK_SECRET stored in Cloudflare Secrets Store + Workers secrets
  • Verify real Mercury transaction event triggers auto-classification and DB persistence

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Per-tenant webhook secret management endpoint.
    • Tenant-scoped Mercury webhook that accepts signed or unsigned native events and returns transaction classification in responses.
  • Improvements

    • 7-day idempotency to prevent duplicate transaction processing.
    • Stronger payload validation with advisory-only schema checks before persisting.
    • System-level account lookup to better resolve accounts and improved transaction logging/response.

Copilot AI review requested due to automatic review settings April 20, 2026 02:21
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@chitcommit chitcommit enabled auto-merge April 20, 2026 02:22
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

Adds a tenant-scoped Mercury webhook POST that consumes raw bodies, optionally verifies per-tenant HMAC signatures from KV, KV-deduplicates events (7d), validates envelopes, ingests transaction resources (resolve/create account, advisory validation, persist, ledger log), plus a PUT to store tenant secrets; adds system lookup by account externalId and updates legacy webhook to use normalized schemas.

Changes

Cohort / File(s) Summary
Mercury Webhook Routes
server/routes/webhooks.ts
Added POST /api/webhooks/mercury/:tenantId (raw body consumption, optional per-tenant HMAC-SHA256 Mercury-Signature verification via KV, Zod envelope validation, 7-day KV dedupe by event.id, process only resourceType === 'transaction', extract from mergePatch, resolve/create account, advisory validateRow() checks, persist via storage.createTransaction(), ledger logging, returns 201 on create). Added PUT /api/webhooks/mercury/:tenantId/secret to store per-tenant secrets (bearer service-token auth). Updated legacy POST /api/webhooks/mercury to use normalizedEnvelopeSchema/normalizedTransactionSchema and align ledgerLog/response shaping while preserving dedupe and advisory validation.
System Storage
server/storage/system.ts
Added SystemStorage.lookupAccountByExternalId(externalId: string) which queries schema.accounts by externalId without tenant scoping and returns the first matching account or undefined.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Webhook Client
    participant Endpoint as Webhook Endpoint
    participant KV as KV Store (secrets & dedupe)
    participant Verifier as Signature Verifier
    participant Validator as Schema Validator
    participant Classifier as Account Resolver/Classifier
    participant DB as Storage / DB
    participant Logger as Ledger Logger

    Client->>Endpoint: POST /api/webhooks/mercury/:tenantId (raw body + Mercury-Signature)
    Endpoint->>KV: fetch tenant secret
    Endpoint->>Verifier: verify HMAC-SHA256 over "<timestamp>.<raw_body>"
    Verifier-->>Endpoint: valid / invalid / no-secret
    Endpoint->>Validator: parse & validate envelope
    Validator-->>Endpoint: valid / invalid
    Endpoint->>KV: check/set dedupe key for event.id (7d TTL)
    alt already processed
        KV-->>Endpoint: exists
        Endpoint-->>Client: 200 OK
    else new event
        KV-->>Endpoint: not found (set dedupe)
        Endpoint->>Endpoint: if resourceType !== "transaction" => 200 OK
        Endpoint->>Classifier: extract mergePatch, findAccountCode(), resolve/create account (may call SystemStorage.lookupAccountByExternalId)
        Classifier-->>Endpoint: accountRef
        Endpoint->>Validator: advisory validateRow() (schema advisory only)
        Endpoint->>DB: storage.createTransaction(...)
        DB-->>Endpoint: persisted transaction
        Endpoint->>Logger: ledgerLog(...)
        Endpoint-->>Client: 201 Created (transaction + classification + schemaAdvisory)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I nibble raw bytes at the gate,

Secrets curled in KV's crate,
Deduped hops and ledger light,
Accounts found by starry sight,
A happy rabbit hops — transactions take flight! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Mercury native webhook with HMAC signature verification' accurately summarizes the main changes: adding a Mercury webhook endpoint with signature verification. It directly matches the primary feature introduced in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mercury-native-webhooks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 20, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
chittyfinance 4230de4 Apr 23 2026, 05:54 PM

@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Code Review — PR #99: Mercury Native Webhook with HMAC Signature Verification

Overview

This PR adds a tenant-scoped Mercury native webhook endpoint (/api/webhooks/mercury/:tenantId) that processes Mercury's JSON Merge Patch event format directly, with HMAC-SHA256 signature verification via Web Crypto. The legacy ChittyConnect-normalized path is preserved. Overall the implementation is solid, but there are a few issues worth addressing before merge.


🔴 Critical Issues

1. Missing timestamp replay protection (verifyMercurySignature)

The function extracts timestamp from the header but never validates it against the current time. Mercury's webhook documentation recommends rejecting events older than ~5 minutes to prevent replay attacks. An attacker who intercepts a valid signed payload could replay it indefinitely.

// After extracting timestamp:
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false; // 5-minute window

2. Signature skip is too permissive in production

if (secret) {
  // verify...
} else {
  console.warn('[webhook:mercury] MERCURY_WEBHOOK_SECRET not set — signature verification skipped');
}

The comment says this allows Mercury's verification ping to succeed during registration — but Mercury's verification ping is just a GET or an empty-body POST, not a signed transaction event. Skipping verification entirely (rather than just allowing empty-body pings through) means any actor can POST arbitrary transaction data to this endpoint whenever MERCURY_WEBHOOK_SECRET is unset. Consider restricting the skip to empty/unrecognized payloads only, or at minimum logging at error level rather than warn.


🟡 Bugs / Logic Issues

3. Race condition in account auto-creation

const accounts = await storage.getAccounts(tenantId);
const active = accounts.find((a) => a.isActive);
if (active) {
  accountId = active.id;
} else {
  const created = await storage.createAccount({ ... });

If two webhook events for the same tenant arrive simultaneously and neither finds an active account, both will call createAccount, creating duplicate Mercury Checking accounts. The createAccount call needs a DB-level unique constraint on (tenantId, institution) or similar, or the fallback account creation should be done in a helper with an ON CONFLICT DO NOTHING.

4. Zero-amount transactions slip through

if (amount === null) {
  return c.json({ received: true, operationType: event.operationType }, 200);
}

amount === 0 passes this check and will create a zero-dollar transaction. This is probably unintended (e.g. a balance-adjustment event with no monetary value). Change to if (amount === null || amount === 0) or handle zero explicitly.

5. Empty description string

const description = (patch.bankDescription as string) ?? (patch.counterpartyName as string) ?? '';

If both fields are absent, description is ''. This flows into findAccountCode('') (likely classifying to suspense 9010, which is fine) and into storage.createTransaction where a blank description may violate any future NOT NULL + non-empty constraint. Consider falling back to event.resourceId or 'Mercury Transaction'.


🟡 Security

6. lookupAccountByExternalId is cross-tenant

async lookupAccountByExternalId(externalId: string) {
  const [row] = await this.db.select().from(schema.accounts).where(eq(schema.accounts.externalId, externalId));
  return row;
}

The method returns an account row from any tenant. The caller does check acct.tenantId === tenantId immediately after, which prevents misuse, but the query itself has no tenant filter. If the caller ever changes and the guard is missed, this becomes a cross-tenant data leak. Safer to scope the query:

async lookupAccountByExternalId(externalId: string, tenantId: string) {
  const [row] = await this.db.select().from(schema.accounts)
    .where(and(eq(schema.accounts.externalId, externalId), eq(schema.accounts.tenantId, tenantId)));
  return row;
}

🟢 What's Done Well

  • Web Crypto HMAC implementation is correct and Workers-compatible. The manual constant-time comparison loop is the right approach since crypto.subtle doesn't expose a constant-time compare.
  • KV + DB dual-layer dedup is solid — KV catches retries, DB catches races.
  • mercuryEventSchema is appropriately loose — using z.string() / z.unknown() fields rather than over-validating Mercury's evolving schema.
  • Legacy path preserved with renamed schemas (normalizedTransactionSchema, normalizedEnvelopeSchema) — clean backwards compat.
  • Advisory-only ChittySchema validation correctly never blocks the write.
  • ledgerLog on the new path ensures the audit trail is consistent with the legacy path.

🔵 Minor / Nits

  • classificationConfidence is hardcoded to '0.700' for all non-suspense codes. The legacy path does the same — fine for now, but worth a TODO for per-code confidence scores.
  • The patch.amount sign convention (positive = income, negative = expense) should be verified against Mercury's actual API contract. If Mercury uses absolute values + a separate kind field, the classification would be inverted.
  • The createAccount call passes externalId conditionally (mercuryAccountId ? \mercury:${mercuryAccountId}` : undefined). If mercuryAccountIdis null and the account is created without anexternalId, future webhook events for the same Mercury account will keep falling through to the fallback branch and returning this same account (since it's now the only active one) — which is actually fine, but it means the account will never get its externalIdlinked. Consider a follow-upupdateAccountcall ifmercuryAccountId` is known but was set without it.

Summary

Priority Issue
🔴 Critical Add timestamp replay protection to verifyMercurySignature
🔴 Critical Narrow the signature-skip path (don't skip for transaction events)
🟡 Bug Guard account auto-creation against concurrent webhook race
🟡 Bug Handle amount === 0 explicitly
🟡 Security Scope lookupAccountByExternalId to a single tenant
🟡 Minor Handle empty description fallback

The replay protection fix is the most important — the rest are lower-risk given that MERCURY_WEBHOOK_SECRET is set in production secrets. Happy to discuss any of these in more detail.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
server/routes/webhooks.ts (1)

230-245: Unsafe as string casts and classifier drops the category hint.

Two small things in the extraction block:

  1. patch.bankDescription as string, patch.counterpartyName as string, patch.postedAt as string, patch.accountId as string lie to the compiler — mergePatch is z.record(z.unknown()), so these fields can be null, numbers, objects, etc. If Mercury sends counterpartyName: null, description becomes the string "null". Prefer a narrow schema or typeof x === 'string' ? x : null guards (you already do this correctly for amount).
  2. The legacy path calls findAccountCode(tx.description, tx.category ?? undefined); the native path calls findAccountCode(description) only and ignores patch.category/patch.mercuryCategory. This will give worse L0 classification for any Mercury event that carries a category.
♻️ Proposed fix
-  const patch = (event.mergePatch ?? {}) as Record<string, unknown>;
-  const amount = typeof patch.amount === 'number' ? patch.amount : null;
-  const description = (patch.bankDescription as string) ?? (patch.counterpartyName as string) ?? '';
-  const counterpartyName = (patch.counterpartyName as string) ?? null;
-  const postedAt = (patch.postedAt as string) ?? (event.occurredAt as string) ?? null;
-  const mercuryAccountId = (patch.accountId as string) ?? null;
+  const patch = event.mergePatch ?? {};
+  const asStr = (v: unknown): string | null => (typeof v === 'string' ? v : null);
+  const amount = typeof patch.amount === 'number' ? patch.amount : null;
+  const description = asStr(patch.bankDescription) ?? asStr(patch.counterpartyName) ?? '';
+  const counterpartyName = asStr(patch.counterpartyName);
+  const postedAt = asStr(patch.postedAt) ?? asStr(event.occurredAt) ?? null;
+  const mercuryAccountId = asStr(patch.accountId);
+  const mercuryCategory = asStr(patch.category) ?? asStr(patch.mercuryCategory);
   ...
-  const suggestedCoaCode = findAccountCode(description);
+  const suggestedCoaCode = findAccountCode(description, mercuryCategory ?? undefined);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/webhooks.ts` around lines 230 - 245, The extraction uses unsafe
`as string` casts and drops Mercury's category hint; replace casts with type
guards (e.g., derive description = typeof patch.bankDescription === 'string' ?
patch.bankDescription : typeof patch.counterpartyName === 'string' ?
patch.counterpartyName : '' and similarly set counterpartyName = typeof
patch.counterpartyName === 'string' ? patch.counterpartyName : null, postedAt =
typeof patch.postedAt === 'string' ? patch.postedAt : typeof event.occurredAt
=== 'string' ? event.occurredAt : null, mercuryAccountId = typeof
patch.accountId === 'string' ? patch.accountId : null) so you never end up with
the string "null"; also capture a categoryHint from patch.category or
patch.mercuryCategory using the same typeof string checks and pass that as the
second argument to findAccountCode(description, categoryHint) so L0
classification uses Mercury's category when available.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/webhooks.ts`:
- Around line 215-222: The KV dedup marker is written before processing which
causes transient failures to become permanent duplicates; change the flow in the
webhook handler so the KV put to c.env.FINANCE_KV (dedupKey) only happens after
a successful database insert (i.e., after createTransaction and after
getTransactionByExternalId confirms the row exists), or implement an "in-flight"
marker (short TTL) first and then replace/upgrade it to the 7-day TTL only upon
successful createTransaction; update references to kv, dedupKey,
createTransaction, and getTransactionByExternalId accordingly.
- Around line 181-182: The route handler starting at webhookRoutes.post where
tenantId is read directly from c.req.param('tenantId') must validate and verify
the tenant before any createAccount/createTransaction/audit actions: parse and
validate tenantId as a UUID using the Zod schemas from `@shared/schema`, then call
storage.getTenant(tenantId) (or the SystemStorage equivalent) and return a
404/unknown_tenant JSON response if missing; when resolving accounts use
getAccountByExternalId(externalId, tenantId) (only fall back to
lookupAccountByExternalId for diagnostics) so all account creation/transaction
insertion and audit logging include the tenant context and avoid the
cross-tenant fallback.
- Around line 184-196: Current logic allows unsigned webhooks when
MERCURY_WEBHOOK_SECRET is unset, which is a security risk; change the handler so
signature verification is required for all non-ping requests by making
MERCURY_WEBHOOK_SECRET mandatory (i.e., if secret is falsy, reject with 401) or,
if you must allow unsigned registration pings only, make the fallback explicit
behind a dedicated env flag like MERCURY_WEBHOOK_ALLOW_UNSIGNED and only skip
verifyMercurySignature(rawBody, signatureHeader, secret) when that flag is true
and the request shape matches the known registration ping (empty/non-JSON) —
reference MERCURY_WEBHOOK_SECRET, signatureHeader, rawBody,
verifyMercurySignature and the webhook handler code to implement this.
- Around line 32-71: The verifyMercurySignature function parses the
t=<timestamp> but never checks freshness; modify verifyMercurySignature to parse
timestamp as an integer (e.g., const ts = parseInt(timestamp, 10)), compute the
difference against Date.now() (convert ts to ms), and return false if the
timestamp is not numeric or is older than 5 minutes (5 * 60 * 1000 ms) or
unreasonably in the future (allow small clock skew, e.g., 1 minute); keep the
existing HMAC computation and constant-time comparison but add this early
timestamp validation using the existing timestamp variable to prevent replay
attacks.

In `@server/storage/system.ts`:
- Around line 77-85: The helper lookupAccountByExternalId currently queries
schema.accounts without tenant isolation; change its signature to accept an
optional tenantId parameter, apply a .where(eq(schema.accounts.tenantId,
tenantId)) when tenantId is provided, and keep a deterministic
.orderBy(schema.accounts.id) with .limit(1) to ensure consistent results; update
the JSDoc for lookupAccountByExternalId to document the cross-tenant risk and
the new optional tenantId behavior so callers (e.g., server/routes/webhooks.ts)
must pass tenant context or handle multiple matches explicitly.

---

Nitpick comments:
In `@server/routes/webhooks.ts`:
- Around line 230-245: The extraction uses unsafe `as string` casts and drops
Mercury's category hint; replace casts with type guards (e.g., derive
description = typeof patch.bankDescription === 'string' ? patch.bankDescription
: typeof patch.counterpartyName === 'string' ? patch.counterpartyName : '' and
similarly set counterpartyName = typeof patch.counterpartyName === 'string' ?
patch.counterpartyName : null, postedAt = typeof patch.postedAt === 'string' ?
patch.postedAt : typeof event.occurredAt === 'string' ? event.occurredAt : null,
mercuryAccountId = typeof patch.accountId === 'string' ? patch.accountId : null)
so you never end up with the string "null"; also capture a categoryHint from
patch.category or patch.mercuryCategory using the same typeof string checks and
pass that as the second argument to findAccountCode(description, categoryHint)
so L0 classification uses Mercury's category when available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 01a8e277-7342-4ab0-92d0-65190bbbdd06

📥 Commits

Reviewing files that changed from the base of the PR and between 18f577c and 93de098.

📒 Files selected for processing (2)
  • server/routes/webhooks.ts
  • server/storage/system.ts

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts Outdated
Comment thread server/routes/webhooks.ts
Comment thread server/storage/system.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Mercury-native webhook ingestion path to the public webhook router, including HMAC signature verification and tenant-scoped routing, while preserving the legacy ChittyConnect-normalized Mercury webhook endpoint.

Changes:

  • Introduces /api/webhooks/mercury/:tenantId to ingest Mercury’s native event envelope, dedup events (KV + DB), and persist/classify transactions.
  • Adds Web Crypto-based HMAC-SHA256 verification for Mercury-Signature.
  • Adds a cross-tenant account lookup helper and refactors the legacy Mercury webhook schema naming/logging.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
server/routes/webhooks.ts Adds Mercury native webhook endpoint with HMAC verification, merge-patch field extraction, dedup, persistence + classification; keeps legacy endpoint with minor refactors.
server/storage/system.ts Adds lookupAccountByExternalId() helper used by the new webhook path for account resolution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts Outdated
Comment thread server/storage/system.ts
Comment thread server/routes/webhooks.ts Outdated
Comment thread server/routes/webhooks.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93de098267

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Code Review — PR #99: Mercury native webhook with HMAC signature verification

Overview

This PR adds a native Mercury webhook endpoint (POST /api/webhooks/mercury/:tenantId) with HMAC-SHA256 verification, per-tenant KV secret storage, auto-classification, and account auto-creation. The legacy ChittyConnect-normalized path is preserved. The implementation is solid overall, but there are a few issues worth addressing before merge.


🔴 Security Issues

1. Missing timestamp replay-attack validation in verifyMercurySignature

The function extracts t=<timestamp> from the Mercury-Signature header and includes it in the signed payload, but never validates that the timestamp is recent. Mercury's signing scheme is modeled on Stripe's — the expectation is that you reject signatures older than ~5 minutes to prevent replay attacks.

// Add after extracting timestamp:
const ts = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(ts) || Math.abs(now - ts) > 300) return false; // 5-minute window

2. lookupAccountByExternalId is a cross-tenant query

server/storage/system.ts queries accounts without a tenant filter. There is a post-query check (acct.tenantId === tenantId), but a cross-tenant query is a bad pattern in a multi-tenant system — it could accidentally match an account that belongs to a different tenant if the externalId collides (Mercury account IDs are not scoped to a tenant on their end).

// Safer: filter by tenantId in the query
async lookupAccountByExternalId(externalId: string, tenantId: string) {
  const [row] = await this.db
    .select()
    .from(schema.accounts)
    .where(and(eq(schema.accounts.externalId, externalId), eq(schema.accounts.tenantId, tenantId)));
  return row;
}

Then remove the acct.tenantId === tenantId guard at the call site (it becomes redundant).

3. Fallback account creation is not idempotent

If the KV write succeeds but the DB write fails (or vice versa), a retry will re-enter the account-creation fallback and attempt to create a second "Mercury Checking" account. The KV dedup key was already written so the retry would bypass it. Consider at minimum adding a name + tenantId unique constraint in the schema, or querying by name before creating.


🟡 Code Quality Issues

4. findAccountCode called without category on the native path

Legacy path: findAccountCode(tx.description, tx.category ?? undefined)
Native path: findAccountCode(description) ← category is available in patch.category

This reduces classification accuracy for the new path. Pass (patch.category as string) ?? undefined as the second argument.

5. Hardcoded confidence magic numbers should be constants

'0.700' and '0.100' appear in two places now (native and legacy paths). Lift them into named constants at the top of the file:

const CONFIDENCE_KEYWORD_MATCH = '0.700';
const CONFIDENCE_SUSPENSE = '0.100';

6. Status code inconsistency between legacy and native paths

  • Legacy path returns 202 for envelope-only events
  • Native path returns 200 for non-transaction events

Pick one and be consistent. 200 is the right choice here since 202 Accepted implies async processing.

7. operationType === 'update' events silently drop

When Mercury sends an update event (e.g., a description or status change on an existing transaction), the handler acks with 200 and does nothing. This is noted in the PR but worth a TODO comment so it doesn't get forgotten:

// TODO: handle 'update' events — patch description/status on existing transaction
return c.json({ received: true, operationType: event.operationType }, 200);

🟢 What's Done Well

  • HMAC constant-time comparison is implemented correctly (no early exit in the loop).
  • KV-first idempotency before the DB write is the right order — avoids DB round-trips on duplicates.
  • Graceful ping handling: unrecognized/empty payloads are acked without error, which is necessary for Mercury's registration flow.
  • mercuryEventSchema is forward-compatible: z.record(z.unknown()) for mergePatch and previousValues avoids breaking on new fields.
  • ledgerLog on ingest continues the audit trail pattern from the legacy path.

Test Coverage

The critical test path (real transaction event → classification → DB persistence) is still unchecked in the test plan. Before this goes to production for non-ARIBIA tenants, consider adding:

  • Unit test for verifyMercurySignature (valid sig, expired timestamp, tampered body, missing header)
  • Integration test for the full webhook flow with a fixture event payload

Summary

Severity Issue
🔴 High Missing timestamp window check (replay attacks)
🔴 High Cross-tenant lookupAccountByExternalId
🔴 Medium Non-idempotent account creation fallback
🟡 Low findAccountCode missing category on native path
🟡 Low Magic number confidence values
🟡 Low Status code inconsistency (200 vs 202)

The replay-attack gap and cross-tenant query are the blockers — everything else can be a follow-up. Happy to pair on the fixes if useful.

Copilot AI review requested due to automatic review settings April 20, 2026 02:32
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the webhook route file and system storage
cat server/routes/webhooks.ts
echo "---SEPARATOR---"
cat server/storage/system.ts

Length of output: 57698


@github-actions[bot] Here is the security-focused review of PR #99:


🔐 Security Review — PR #99: Mercury Native Webhook

1. 🚨 Critical — Signature Verification Bypass Opens Unauthenticated Transaction Injection

The most severe issue in this PR:

// server/routes/webhooks.ts
if (secret) {
  if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
    return c.json({ error: 'invalid_signature' }, 401);
  }
} else {
  // ⚠️ No guard — falls through to full transaction processing
  console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
}

When neither webhook:mercury:secret:<tenantId> (KV) nor MERCURY_WEBHOOK_SECRET (env) is set, any unauthenticated HTTP POST can write financial transactions to the database for any tenantId. This is not just a "registration ping" window — it is an open write path until a secret is explicitly configured per tenant.

Recommendation: Return 401 immediately when no secret is configured, unless the body is provably empty or a recognized Mercury ping shape. The current fallback to console.warn and continued processing is insufficient for financial data.

// Suggested fix: fail-closed
if (!secret) {
  return c.json({ error: 'webhook_not_configured' }, 401);
}

2. 🔴 High — No Timestamp Replay-Window Validation in verifyMercurySignature

const parts = signatureHeader.split(',');
let timestamp: string | undefined;
// timestamp is extracted but never compared to Date.now()

The HMAC implementation correctly reconstructs ${timestamp}.${rawBody} and uses constant-time comparison, but the t= timestamp from the header is never validated against the wall clock. An attacker who captures a valid signed payload can replay it at any future time. While KV deduplication protects against same-event-ID replays, a valid payload with a novel event.id could be replayed indefinitely.

Mercury's own documentation recommends rejecting events older than 5 minutes.

Recommendation:

const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; // 5 minutes
const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() - ts * 1000) > MAX_CLOCK_SKEW_MS) return false;

3. 🔴 High — tenantId URL Parameter Is Never Validated Against the Database

const tenantId = c.req.param('tenantId');
// ... used directly in createTransaction, createAccount, etc.

The tenantId is taken verbatim from the URL path and never checked against the tenants table. A request to /api/webhooks/mercury/00000000-0000-0000-0000-000000000000 will happily create accounts and transactions under a phantom tenant ID. This could be used to pollute the database or probe for valid tenant IDs by timing account-creation vs account-lookup paths.

Recommendation: Add a tenant existence check early in the handler:

const tenant = await storage.getTenant(tenantId);
if (!tenant) return c.json({ error: 'invalid_tenant' }, 400);

4. 🟠 Medium — Cross-Tenant lookupAccountByExternalId Without Scoping

// server/storage/system.ts
async lookupAccountByExternalId(externalId: string) {
  const [row] = await this.db
    .select()
    .from(schema.accounts)
    .where(eq(schema.accounts.externalId, externalId)); // ← no tenantId filter
  return row;
}

The post-lookup guard if (acct && acct.tenantId === tenantId) mitigates direct data leakage, but this method still performs a cross-tenant DB scan and returns a row belonging to any tenant. A timing oracle could allow an attacker to determine whether a given Mercury account ID is registered in the system across tenants. Given the existing getAccountByExternalId(externalId, tenantId) method with proper tenant scoping, prefer using that, or pass tenantId into lookupAccountByExternalId.


5. 🟠 Medium — PUT /secret Service Token Comparison Is Not Constant-Time

// server/routes/webhooks.ts
if (!token || token !== expected) return c.json({ error: 'unauthorized' }, 401);

JavaScript's !== operator short-circuits on the first differing character, creating a timing side-channel. While exploiting this over HTTPS with network jitter is difficult, it is inconsistent with the constant-time comparison used in verifyMercurySignature.

Recommendation: Use the same constant-time approach:

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let result = 0;
  for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return result === 0;
}
if (!token || !timingSafeEqual(token, expected)) return c.json({ error: 'unauthorized' }, 401);

6. 🟠 Medium — Raw Financial Data Retained in KV for 7 Days

await kv.put(dedupKey, rawBody, { expirationTtl: 604800 }); // 7 days

Mercury mergePatch payloads contain PII-adjacent financial data (counterparty names, amounts, account IDs). Storing the full rawBody in KV for deduplication means this data persists in a secondary store for a week. Only a dedup token (e.g., event.id + "1") is needed.

Recommendation:

await kv.put(dedupKey, '1', { expirationTtl: 604800 }); // store sentinel, not payload

7. 🟡 Low — No Request Body Size Guard

There is no Content-Length or body-size check before await c.req.text(). A large forged payload could consume memory and KV quota. Cloudflare Workers have a 128 MB body limit, but a reasonable application-level guard (e.g., 64 KB) is advisable for webhook endpoints.


8. ⚠️ Potential Breaking Change — Legacy /api/webhooks/mercury Schema

The legacy endpoint's input schema was changed from the old Mercury envelope to normalizedEnvelopeSchema. If ChittyConnect or any other caller was sending payloads that matched the previous schema shape, the validation would now return 400 invalid_envelope. The response status for duplicates also diverges: the native endpoint returns 200 for duplicates while the legacy path returns 202.

Recommendation: Verify that ChittyConnect's outbound payload shape matches normalizedEnvelopeSchema before merging, and align duplicate response codes.


9. ✅ Credential Exposure — No Issues Found

  • MERCURY_WEBHOOK_SECRET is consumed only from c.env (Cloudflare Worker secrets), not logged.
  • Per-tenant secrets stored in KV are not echoed in responses.
  • The PUT /secret response returns only { stored: true, tenantId }.
  • HMAC key material is never logged.

10. ✅ Dependency Supply Chain — No New External Dependencies

The implementation uses only:

  • hono, zod, drizzle-orm — already in the dependency tree
  • Web Crypto API (built into the Cloudflare Workers runtime) — no third-party HMAC library added

This is the correct approach and introduces no new supply chain surface.


Summary Table

Severity Finding
🚨 Critical Signature bypass — unauthenticated transaction writes when no secret configured
🔴 High No HMAC timestamp replay-window validation
🔴 High tenantId URL param not validated against DB
🟠 Medium Cross-tenant lookupAccountByExternalId (timing oracle)
🟠 Medium Service token non-constant-time comparison in PUT /secret
🟠 Medium Full raw financial payload stored in KV (store sentinel instead)
🟡 Low No body size guard before c.req.text()
⚠️ Potential breaking schema change on legacy Mercury path
No credential exposure in code
No new supply chain dependencies

The critical signature bypass (item 1) should be resolved before this is deployed to production tenants beyond the single test registration already in place.


🧠 Learnings used
Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-18T01:23:18.963Z
Learning: Applies to server/routes/webhooks.ts : Implement idempotent webhook event processing by deduplicating events using KV storage with 7-day TTL for Stripe and Mercury webhook handlers
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the webhook routes file and storage system file
cat server/routes/webhooks.ts
echo "---FILE BOUNDARY---"
cat server/storage/system.ts

Length of output: 57668


@github-actions[bot] Here is the security-focused evaluation of PR #99.


🔐 Security Review — PR #99: Mercury Native Webhook

1. 🔴 Critical — Signature Verification Bypass Enables Unauthenticated Transaction Ingestion

File: server/routes/webhooks.ts — Lines ~202–212

if (secret) {
  if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
    return c.json({ error: 'invalid_signature' }, 401);
  }
} else {
  // ⚠️ No secret → verification fully skipped
  console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
}

When no KV secret exists for a tenantId, any unauthenticated HTTP POST to /api/webhooks/mercury/:tenantId can fabricate financial transactions and trigger auto-account creation. The "registration ping" justification only applies momentarily during initial webhook registration with Mercury — after that, the absence of a secret leaves the endpoint permanently open. The fix should enforce an explicit "registration mode" flag rather than silently falling through:

// Suggested: require explicit opt-in for ping mode
const pingMode = c.req.header('X-Mercury-Ping') === 'true';
if (!secret && !pingMode) {
  return c.json({ error: 'webhook_not_configured' }, 503);
}
if (secret && (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret)))) {
  return c.json({ error: 'invalid_signature' }, 401);
}

2. 🟠 High — No Timestamp Replay Window in HMAC Verification

File: server/routes/webhooks.tsverifyMercurySignature()

The t=<unix_timestamp> is extracted from the Mercury-Signature header but never validated against the current time. A captured valid request can be replayed indefinitely after the 7-day KV idempotency window expires (or against any tenant that does not yet have a dedup entry). The industry standard (Stripe, Twilio) is to reject timestamps older than 5 minutes:

// Add after extracting timestamp/signature:
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(now - ts) > 300) return false; // 5-minute window

3. 🟠 High — Auto-Account Creation Behind Unauthenticated Path

File: server/routes/webhooks.ts — Lines ~274–290

When no active account exists for a tenant, the webhook handler automatically calls storage.createAccount(). Combined with finding #1, an unauthenticated attacker who knows a valid tenantId can create arbitrary Mercury Checking accounts in the database. This should be gated behind verified signature at minimum, and ideally require a pre-existing account.


4. 🟡 Medium — Cross-Tenant SQL Scan in lookupAccountByExternalId

File: server/storage/system.ts — Lines ~71–77

async lookupAccountByExternalId(externalId: string) {
  const [row] = await this.db
    .select()
    .from(schema.accounts)
    .where(eq(schema.accounts.externalId, externalId)); // ← no tenant filter
  return row;
}

This method performs a database-wide scan with no tenant scope. The webhook handler does re-check acct.tenantId === tenantId after the call, but this is a defense-in-depth gap — the DB query still touches all tenants' account data. Consider accepting tenantId as a parameter and applying it in the WHERE clause, or at minimum adding a DB-level index on (external_id, tenant_id) and using the scoped existing method getAccountByExternalId(externalId, tenantId) instead.


5. 🟡 Medium — Credential Exposure: Webhook Secrets in Plain-Text KV

File: server/routes/webhooks.tsPUT /api/webhooks/mercury/:tenantId/secret

await kv.put(`webhook:mercury:secret:${tenantId}`, secret);

Per-tenant webhook secrets are stored in plain-text KV. While Cloudflare KV is encrypted at rest at the infrastructure level, the secrets are readable by any Worker code or tooling with KV namespace access (e.g., wrangler kv key get). Consider using Cloudflare Secrets Store or at minimum marking the KV value as metadata-only (not listable) to reduce exposure surface.


6. 🟡 Medium — PII/Financial Data Logged for Unrecognized Payloads

File: server/routes/webhooks.ts — Line ~228

console.warn('[webhook:mercury] Unrecognized payload, acking', { tenantId, body });

The full raw body (which may contain account numbers, transaction amounts, counterparty names, or other PII) is emitted to Cloudflare Workers logs. This should be redacted to just structural fields like { tenantId, bodyKeys: Object.keys(body) }.


7. 🟡 Medium — Potential Breaking Change on Legacy Endpoint

File: server/routes/webhooks.tsPOST /api/webhooks/mercury

The old Mercury transaction envelope schema was replaced by normalizedEnvelopeSchema + normalizedTransactionSchema. If any version of the ChittyConnect normalizer still sends the old envelope shape (e.g., top-level transaction field rather than nested under data.transaction), those events will now fail with 400 invalid_envelope. The PR description notes the legacy path is "retained for backwards compat," but the schema change is not backwards compatible in the strict sense. A migration test or at least a contract test between ChittyConnect and this schema is recommended.


8. ✅ Supply Chain — No New External Dependencies

Only hono, zod, and the built-in Web Crypto API (crypto.subtle) are used. No new third-party packages are introduced. The HMAC implementation using crypto.subtle.importKey + crypto.subtle.sign is correct and Workers-compatible. The constant-time comparison loop is implemented correctly with the result |= charCodeAt XOR pattern.


Summary

# Severity Finding
1 🔴 Critical Signature verification fully bypassed when no KV secret exists
2 🟠 High No timestamp replay window — 7-day dedup bypass possible
3 🟠 High Auto-account creation behind unauthenticated path
4 🟡 Medium Cross-tenant DB scan in lookupAccountByExternalId
5 🟡 Medium Per-tenant webhook secrets stored in plain-text KV
6 🟡 Medium Full request body (potential PII) logged on unrecognized payloads
7 🟡 Medium Legacy endpoint schema change may break existing ChittyConnect adapters
8 ✅ Clean No new external dependencies; supply chain risk is low

🧠 Learnings used
Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-18T01:23:18.963Z
Learning: Applies to server/routes/webhooks.ts : Implement idempotent webhook event processing by deduplicating events using KV storage with 7-day TTL for Stripe and Mercury webhook handlers
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Code Review — PR #99: Mercury Native Webhook with HMAC Verification

Good direction overall — moving to per-tenant native webhook registration is the right architecture. The Web Crypto HMAC implementation is solid. A few security and correctness issues to address before merge.


🔴 Critical: Signature Skip Creates an Open Injection Window

if (secret) {
  if (!signatureHeader || !(await verifyMercurySignature(...))) {
    return c.json({ error: 'invalid_signature' }, 401);
  }
} else {
  console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
}

Any unauthenticated POST to /api/webhooks/mercury/:tenantId is accepted until a secret is registered. Combined with the auto-account-creation fallback below, an attacker who discovers the endpoint can inject transactions into any tenant's ledger and silently create new accounts. Even if the window is brief during setup, it's a real risk.

Fix: Enforce a strict registration flow — reject with 401 if no secret exists, and document a separate one-time registration ping mechanism if Mercury requires it. Or only skip verification when the body is an empty/known Mercury ping pattern before any DB writes.


🔴 No Timestamp Replay Protection

verifyMercurySignature parses t=<timestamp> from the header but never validates it for freshness. A captured valid request can be replayed indefinitely as long as the event ID isn't in KV (and KV dedup expires after 7 days).

Fix: Add a tolerance check (Stripe uses ±5 minutes):

const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

🔴 KV Dedup Written Before DB Write — Silent Loss on DB Failure

await kv.put(dedupKey, rawBody, { expirationTtl: 604800 });
// ... later ...
const created = await storage.createTransaction(...);

If the DB write throws after KV is written, Mercury will retry but KV will return a duplicate hit and the transaction is permanently lost — no retry, no alert.

Fix: Write to KV only after a successful DB persist, or wrap in a try/catch that deletes the KV key on DB failure.


🟡 lookupAccountByExternalId Is Cross-Tenant

async lookupAccountByExternalId(externalId: string) {
  const [row] = await this.db.select().from(schema.accounts)
    .where(eq(schema.accounts.externalId, externalId));
  return row;
}

The caller guards with if (acct && acct.tenantId === tenantId), but a cross-tenant query is a footgun — future callers may omit the check. Pass tenantId to the storage method and filter at the query level.


🟡 Auto-Account Creation as a Side Effect of Unauthenticated (or Minimally Verified) Request

When no Mercury account can be resolved, the handler silently creates one:

const created = await storage.createAccount({ tenantId, name: 'Mercury Checking', ... });

This runs even when signature verification was skipped (no secret configured). Orphaned accounts in production are hard to clean up and confuse reconciliation.

Fix: Only auto-create accounts after signature verification is confirmed. Consider making this opt-in via a query param or config flag.


🟡 No UUID Validation on tenantId in Secret Store Endpoint

webhookRoutes.put('/api/webhooks/mercury/:tenantId/secret', async (c) => {
  ...
  const tenantId = c.req.param('tenantId');
  await kv.put(`webhook:mercury:secret:${tenantId}`, secret);

Any string is accepted as a KV key. Validate UUID format and confirm the tenant exists before writing.


🟡 Invalid Date on Bad postedAt String

date: postedAt ? new Date(postedAt) : new Date(),

new Date('garbage') returns an Invalid Date object — it doesn't throw. This silently persists a broken date to the DB. Add a validation step:

const parsedDate = postedAt ? new Date(postedAt) : new Date();
if (isNaN(parsedDate.getTime())) parsedDate = new Date();

🟡 amount Type Assumption

const amount = typeof patch.amount === 'number' ? patch.amount : null;

Mercury's API has historically returned amounts as strings in some contexts (e.g. "amount": "1500.00"). Consider Number(patch.amount) with an isNaN guard to handle both.


🟢 Positive Notes

  • Web Crypto HMAC implementation is correct and Workers-compatible. The constant-time comparison using XOR is the right pattern.
  • Dual dedup (KV + DB) is good defense-in-depth for idempotency.
  • Backwards-compatible legacy path is preserved cleanly.
  • Advisory ChittySchema validation pattern (never blocks, only warns) is consistent with the existing classification trust model.
  • Renaming schemas (normalizedTransactionSchema, normalizedEnvelopeSchema) for clarity is a nice cleanup.
  • The ledgerLog refactor (removing multi-line wrapping) is a minor style win.

Minor

  • mercuryEventSchema.resourceType and operationType are z.string(). Consider z.enum(['transaction', 'account', ...]) for known types — makes unhandled event types explicit.
  • The fixed confidence values ('0.100', '0.700') are duplicated between both handler paths. A shared constant would help.
  • The test plan item for real transaction → DB persistence is still unchecked. Would be good to get that verified before merge given the classification and account-resolution logic is the core of this PR.

Summary: The core HMAC verification logic and architecture are sound. The three 🔴 items (signature bypass window, no replay protection, KV-before-DB ordering) are the blocking issues. The 🟡 items are worth addressing in this PR since they're directly in the modified code paths.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts Outdated
Comment thread server/routes/webhooks.ts Outdated
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/storage/system.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
server/routes/webhooks.ts (4)

201-202: ⚠️ Potential issue | 🟠 Major

Validate the tenant and make account lookup tenant-scoped.

tenantId is trusted from the URL, and lookupAccountByExternalId searches across all tenants before the tenant check. Validate the UUID, verify the tenant exists before any create/insert path, and resolve Mercury accounts with a tenant-scoped storage method.

🛡️ Suggested direction
-  const tenantId = c.req.param('tenantId');
+  const tenantIdResult = z.string().uuid().safeParse(c.req.param('tenantId'));
+  if (!tenantIdResult.success) return c.json({ error: 'invalid_tenant_id' }, 400);
+  const tenantId = tenantIdResult.data;
   const db = createDb(c.env.DATABASE_URL);
   const storage = new SystemStorage(db);
+
+  const tenant = await storage.getTenant(tenantId);
+  if (!tenant) return c.json({ error: 'unknown_tenant' }, 404);
-    const acct = await storage.lookupAccountByExternalId(`mercury:${mercuryAccountId}`);
-    if (acct && acct.tenantId === tenantId) {
+    const acct = await storage.getAccountByExternalId(`mercury:${mercuryAccountId}`, tenantId);
+    if (acct) {
       accountId = acct.id;
     }

As per coding guidelines: “For multi-tenant operations in system mode, always include tenant context in queries to ensure data isolation and prevent cross-tenant data leaks”.

Also applies to: 272-304

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/webhooks.ts` around lines 201 - 202, The route handler starting
at webhookRoutes.post('/api/webhooks/mercury/:tenantId') currently trusts
tenantId from the URL and calls lookupAccountByExternalId across all tenants;
fix this by (1) validating tenantId is a proper UUID, (2) resolving and
asserting the tenant exists before any create/insert logic, and (3) changing the
Mercury account resolution to use a tenant-scoped lookup (e.g., call
lookupAccountByExternalId with a tenantId parameter or replace it with a
tenant-aware method such as lookupAccountByExternalIdForTenant /
resolveMercuryAccountWithTenant) so all queries include tenant context and
prevent cross-tenant lookups or inserts.

239-245: ⚠️ Potential issue | 🟠 Major

Write a tenant-scoped sentinel after successful processing.

The current KV marker is global to event.id, stores the full financial payload, and is written before DB persistence. Scope the key by tenant, store a minimal sentinel, and only promote it after createTransaction succeeds; otherwise Mercury retries can be incorrectly treated as duplicates.

🛡️ Safer dedup pattern
-  const dedupKey = `webhook:mercury:${event.id}`;
+  const dedupKey = `webhook:mercury:${tenantId}:${event.id}`;
   const existing = await kv.get(dedupKey);
   if (existing) {
     return c.json({ received: true, duplicate: true }, 200);
   }
-  await kv.put(dedupKey, rawBody, { expirationTtl: 604800 });
+  // Put `await kv.put(dedupKey, '1', { expirationTtl: 604800 })`
+  // only after the transaction insert succeeds, or after confirming a DB duplicate.

Based on learnings: “Implement idempotent webhook event processing by deduplicating events using KV storage with 7-day TTL for Stripe and Mercury webhook handlers”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/webhooks.ts` around lines 239 - 245, The dedup logic currently
writes a global KV entry with the full payload before DB persistence; change it
to first check a tenant-scoped key (e.g.,
`webhook:mercury:${tenantId}:${event.id}`) and only write a minimal sentinel
(not rawBody) after createTransaction returns success. Specifically: use the
existing kv.get(dedupKey) read but build dedupKey to include tenant identifier,
avoid storing rawBody (store a small constant or timestamp), and move the kv.put
call to run only after the createTransaction(...) call completes successfully so
retries aren't marked duplicate before DB persistence.

32-50: ⚠️ Potential issue | 🟠 Major

Reject stale Mercury signatures before computing the HMAC.

timestamp is parsed but never checked, so a captured valid (Mercury-Signature, body) can be replayed indefinitely once KV dedup expires or misses. Add a short replay window and small future-skew allowance.

🔒 Proposed replay-window check
   if (!timestamp || !signature) return false;
+
+  const ts = Number.parseInt(timestamp, 10);
+  if (!Number.isFinite(ts)) return false;
+
+  const nowMs = Date.now();
+  const timestampMs = ts * 1000;
+  const maxAgeMs = 5 * 60 * 1000;
+  const maxFutureSkewMs = 60 * 1000;
+  if (nowMs - timestampMs > maxAgeMs || timestampMs - nowMs > maxFutureSkewMs) {
+    return false;
+  }
 
   const signedPayload = `${timestamp}.${rawBody}`;

Please verify the exact current tolerance against Mercury’s webhook docs:

Mercury webhook Mercury-Signature timestamp replay tolerance t v1 HMAC SHA-256
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/webhooks.ts` around lines 32 - 50, In verifyMercurySignature,
reject stale or far-future timestamps before computing the HMAC: parse the
numeric timestamp variable and compare Date.now()/Math.floor to enforce a short
replay window (e.g., 5 minutes) and a small future-skew allowance (e.g., 1-2
minutes) — if timestamp is outside [now - replayWindow, now + futureSkew] return
false immediately; only then build signedPayload and compute/compare the v1
HMAC. Update the function signature verifyMercurySignature and the local
variables timestamp/signature handling accordingly and confirm the exact window
values against Mercury’s webhook docs.

204-220: ⚠️ Potential issue | 🔴 Critical

Fail closed when the tenant webhook secret is missing.

Line 219 allows unsigned JSON payloads to continue into transaction creation. Keep unsigned handling only for a provable registration ping, and reject all processable events when no tenant secret exists. Also update the stale “fallback” comment because line 211 only reads KV.

🔒 Proposed fail-closed gate
-  // Falls back to MERCURY_WEBHOOK_SECRET env var (shared/legacy).
-  // Skips verification entirely if no secret found (allows registration ping).
+  // Skips verification only for empty registration pings; processable events require a per-tenant secret.
   const rawBody = await c.req.text();
   const kv = c.env.FINANCE_KV;
   const secret = await kv.get(`webhook:mercury:secret:${tenantId}`);
   const signatureHeader = c.req.header('Mercury-Signature') ?? '';
 
-  if (secret) {
-    if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
-      return c.json({ error: 'invalid_signature' }, 401);
-    }
-  } else {
-    console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
+  if (!secret) {
+    if (!rawBody.trim()) return c.json({ received: true }, 200);
+    console.error('[webhook:mercury] No secret configured for tenant', tenantId);
+    return c.json({ error: 'webhook_not_configured' }, 503);
+  }
+
+  if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
+    return c.json({ error: 'invalid_signature' }, 401);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/webhooks.ts` around lines 204 - 220, The current webhook
verification falls back to allowing unsigned payloads when no tenant secret
exists; change this to fail closed: after retrieving secret from
kv.get(`webhook:mercury:secret:${tenantId}`) and reading
rawBody/signatureHeader, if secret is missing only allow the request to proceed
when the payload is a provable registration ping (parse rawBody and check the
event/type field explicitly for the registration ping value), otherwise return
c.json({ error: 'invalid_signature' }, 401); keep the existing
verifyMercurySignature(secret, rawBody, signatureHeader) path when secret
exists, and update the comment above the block to remove the stale “fallback”
mention of MERCURY_WEBHOOK_SECRET.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/webhooks.ts`:
- Around line 230-234: The current webhook handler logs the entire incoming body
on schema parse failure which may leak sensitive fields; update the failure
branch around mercuryEventSchema.safeParse(body) (variables: parsed, body,
tenantId) to stop logging body and instead log only non-sensitive validation
metadata such as parsed.error or parsed.success=false plus tenantId and a short
reason; remove or redact any direct body output and, if helpful, add a minimal
checksum/size flag (not contents) to aid debugging while preserving privacy.
- Around line 252-259: Validate event.mergePatch before using its fields:
replace the unchecked casting around patch (event.mergePatch ?? {}) and
downstream uses of amount, description, counterpartyName, postedAt, and
mercuryAccountId with a Zod schema (imported from `@shared/schema` or create one
matching the expected shape) that enforces types and optionality, parse/validate
mergePatch using schema.safeParse or parse, handle validation failures (log and
abort/ignore malformed payload), and then read the validated result to set
amount, description, counterpartyName, postedAt, and mercuryAccountId so no raw
"as string" or unchecked number casts are used.
- Around line 166-179: The webhook PUT handler
(webhookRoutes.put('/api/webhooks/mercury/:tenantId/secret', async (c) => { ...
})) must validate inputs and harden auth before writing KV: import and use Zod
schemas from `@shared/schema` to validate c.req.param('tenantId') and the parsed
body { secret } (wrap c.req.json(...) in try/catch to return a 400 with
consistent JSON on malformed JSON), perform authorization using a constant-time
comparison (e.g., crypto.timingSafeEqual) against
c.env.CHITTY_AUTH_SERVICE_TOKEN instead of direct token !== expected, and only
call kv.put (using c.env.FINANCE_KV) after both auth and validation succeed;
ensure all error responses use the same JSON error shape and correct HTTP status
codes.

---

Duplicate comments:
In `@server/routes/webhooks.ts`:
- Around line 201-202: The route handler starting at
webhookRoutes.post('/api/webhooks/mercury/:tenantId') currently trusts tenantId
from the URL and calls lookupAccountByExternalId across all tenants; fix this by
(1) validating tenantId is a proper UUID, (2) resolving and asserting the tenant
exists before any create/insert logic, and (3) changing the Mercury account
resolution to use a tenant-scoped lookup (e.g., call lookupAccountByExternalId
with a tenantId parameter or replace it with a tenant-aware method such as
lookupAccountByExternalIdForTenant / resolveMercuryAccountWithTenant) so all
queries include tenant context and prevent cross-tenant lookups or inserts.
- Around line 239-245: The dedup logic currently writes a global KV entry with
the full payload before DB persistence; change it to first check a tenant-scoped
key (e.g., `webhook:mercury:${tenantId}:${event.id}`) and only write a minimal
sentinel (not rawBody) after createTransaction returns success. Specifically:
use the existing kv.get(dedupKey) read but build dedupKey to include tenant
identifier, avoid storing rawBody (store a small constant or timestamp), and
move the kv.put call to run only after the createTransaction(...) call completes
successfully so retries aren't marked duplicate before DB persistence.
- Around line 32-50: In verifyMercurySignature, reject stale or far-future
timestamps before computing the HMAC: parse the numeric timestamp variable and
compare Date.now()/Math.floor to enforce a short replay window (e.g., 5 minutes)
and a small future-skew allowance (e.g., 1-2 minutes) — if timestamp is outside
[now - replayWindow, now + futureSkew] return false immediately; only then build
signedPayload and compute/compare the v1 HMAC. Update the function signature
verifyMercurySignature and the local variables timestamp/signature handling
accordingly and confirm the exact window values against Mercury’s webhook docs.
- Around line 204-220: The current webhook verification falls back to allowing
unsigned payloads when no tenant secret exists; change this to fail closed:
after retrieving secret from kv.get(`webhook:mercury:secret:${tenantId}`) and
reading rawBody/signatureHeader, if secret is missing only allow the request to
proceed when the payload is a provable registration ping (parse rawBody and
check the event/type field explicitly for the registration ping value),
otherwise return c.json({ error: 'invalid_signature' }, 401); keep the existing
verifyMercurySignature(secret, rawBody, signatureHeader) path when secret
exists, and update the comment above the block to remove the stale “fallback”
mention of MERCURY_WEBHOOK_SECRET.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f64e62c-db59-445e-8993-7247f151ad6a

📥 Commits

Reviewing files that changed from the base of the PR and between 93de098 and 2ae2263.

📒 Files selected for processing (1)
  • server/routes/webhooks.ts

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
chitcommit and others added 3 commits April 23, 2026 00:24
Add tenant-scoped Mercury webhook endpoint (POST /api/webhooks/mercury/:tenantId)
that accepts Mercury's native event envelope format (JSON Merge Patch) with
HMAC-SHA256 signature verification via Mercury-Signature header.

- Web Crypto HMAC verification (Workers-compatible, constant-time comparison)
- Graceful fallback when MERCURY_WEBHOOK_SECRET not yet configured
- Auto-classify transactions at ingest (L0→L1 keyword match)
- Auto-create Mercury checking account if none exists for tenant
- KV-based idempotency + DB-level external_id dedup
- Legacy ChittyConnect-normalized path preserved at /api/webhooks/mercury
- Add lookupAccountByExternalId storage method (cross-tenant)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mercury returns a unique signing secret per webhook registration, so a
single MERCURY_WEBHOOK_SECRET env var doesn't work for multiple tenants.

- Look up secret from KV at `webhook:mercury:secret:<tenantId>`
- Fall back to MERCURY_WEBHOOK_SECRET env var (legacy/shared)
- Skip verification if no secret found (allows registration ping)
- Add PUT /api/webhooks/mercury/:tenantId/secret for storing secrets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

When no per-tenant KV secret exists, skip verification instead of
falling back to MERCURY_WEBHOOK_SECRET (which belongs to a different
tenant and causes 401 on registration pings).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chitcommit chitcommit force-pushed the feat/mercury-native-webhooks branch from 2ae2263 to 8a2926b Compare April 23, 2026 05:24
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Tip

For best results, initiate chat on the files or code changes.

@github-actions[bot] Here is the consolidated security review for PR #99, organized around the four requested dimensions.


🔐 Security Review — PR #99: Mercury Native Webhook

1. Security Implications

🚨 Critical — Signature Verification Bypass

When no per-tenant KV secret exists (i.e., before PUT /secret is called for a tenant), the handler logs a warning and falls through to full transaction processing. Any unauthenticated HTTP POST to /api/webhooks/mercury/<any-uuid> can inject financial transactions and trigger auto-account creation.

// Current behavior — falls through when no secret
if (secret) {
  if (!signatureHeader || !(await verifyMercurySignature(...))) return c.json({ error: 'invalid_signature' }, 401);
} else {
  console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
  // ← continues to transaction ingestion
}

Fix — fail closed:

if (!secret) {
  return c.json({ error: 'webhook_not_configured' }, 401);
}
if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
  return c.json({ error: 'invalid_signature' }, 401);
}

If Mercury's registration ping requires a 200 before the secret is known, gate specifically on the ping body shape (e.g., empty mergePatch) rather than skipping all verification.

🔴 High — No Timestamp Replay-Window Validation

verifyMercurySignature extracts the t= timestamp from Mercury-Signature and includes it in the signed string, but never validates it against Date.now(). The 7-day KV dedup window partially helps for identical event.id values, but a valid payload with a novel event.id (or after dedup expiry) can be replayed indefinitely.

// Add inside verifyMercurySignature, after extracting `timestamp`:
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(now - ts) > 300) return false; // reject > 5 min skew

🔴 High — tenantId Not Validated Against the Database

The tenantId path parameter is used directly in createTransaction, createAccount, and KV writes without ever checking that the tenant exists. Requests to /api/webhooks/mercury/00000000-0000-0000-0000-000000000000 will create phantom-tenant data.

// Add early in the handler:
const tenant = await storage.getTenant(tenantId);
if (!tenant) return c.json({ error: 'invalid_tenant' }, 400);

🟠 Medium — Cross-Tenant lookupAccountByExternalId

SystemStorage.lookupAccountByExternalId(externalId) issues a DB-wide scan with no tenant filter. The post-call guard acct.tenantId === tenantId prevents data leakage but still touches all tenants' rows and creates a timing oracle. Pass tenantId into the query or reuse the existing scoped method:

// server/storage/system.ts — prefer scoped lookup
async lookupAccountByExternalId(externalId: string, tenantId: string) {
  const [row] = await this.db.select().from(schema.accounts)
    .where(and(eq(schema.accounts.externalId, externalId), eq(schema.accounts.tenantId, tenantId)));
  return row;
}

🟡 Low — No Request Body Size Guard

await c.req.text() runs before any size check. A crafted large payload could consume memory and inflate KV storage. A 64 KB application-level guard before the text() call is advisable.


2. Credential Exposure Risk

Vector Current State Risk
MERCURY_WEBHOOK_SECRET env var Consumed from c.env, not logged ✅ Safe
Per-tenant KV secrets Stored as plain-text KV values ⚠️ Readable by any Worker or wrangler kv key get
PUT /secret token comparison token !== expectednon-constant-time ⚠️ Timing side-channel
KV dedup values Stores full rawBody (financial payload) for 7 days ⚠️ PII in secondary store
Unrecognized payload log console.warn(..., { tenantId, body })full body logged ⚠️ PII in Cloudflare log drain
HMAC key material Never logged ✅ Safe
PUT /secret response Returns only { stored: true, tenantId } ✅ Safe

Recommended fixes:

// 1. Constant-time token comparison for PUT /secret
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let r = 0;
  for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return r === 0;
}

// 2. Store sentinel in KV, not raw body
await kv.put(dedupKey, '1', { expirationTtl: 604800 });

// 3. Redact body from unrecognized-payload log
console.warn('[webhook:mercury] Unrecognized payload, acking', { tenantId, bodyKeys: Object.keys(body) });

3. Dependency Supply Chain

No new external dependencies introduced. The implementation relies on:

  • hono — already in the dependency tree
  • zod — already in the dependency tree
  • drizzle-orm — already in the dependency tree
  • Web Crypto API (crypto.subtle.importKey, crypto.subtle.sign) — built into the Cloudflare Workers runtime; no third-party HMAC library added

The HMAC construction (${timestamp}.${rawBody}, SHA-256, constant-time comparison via charCodeAt XOR) is implemented correctly and follows the pattern used for Stripe in this codebase. Supply chain risk is low.


4. Breaking API Changes

Endpoint Change Risk
POST /api/webhooks/mercury (legacy) Input schema replaced: old Mercury envelope → normalizedEnvelopeSchema + normalizedTransactionSchema 🟠 Breaking for any ChittyConnect version still sending the old envelope shape
POST /api/webhooks/mercury (legacy) Duplicate response: now returns 202; native path returns 200 ⚠️ Inconsistent — callers relying on exact status code will diverge
POST /api/webhooks/mercury/:tenantId New endpoint — additive ✅ Non-breaking
PUT /api/webhooks/mercury/:tenantId/secret New endpoint — additive ✅ Non-breaking

Recommendation: Verify ChittyConnect's outbound payload schema matches normalizedEnvelopeSchema before merging. If the schema of the legacy endpoint changed, a contract test between ChittyConnect and this endpoint is advisable. Also align duplicate-event response codes across both paths for consistency.


Priority Summary

Priority Item
🚨 Fix before production Signature bypass — return 401 when no secret configured
🔴 Fix before production Add 5-minute timestamp replay window in verifyMercurySignature
🔴 Fix before production Validate tenantId against the tenants table
🟠 Address soon Scope lookupAccountByExternalId to the tenant
🟠 Address soon Constant-time comparison in PUT /secret
🟠 Address soon Store '1' sentinel in KV instead of full rawBody
🟠 Address soon Redact body from unrecognized-payload log
🟡 Nice to have Body size guard before c.req.text()
🟡 Verify ChittyConnect payload compatibility with updated legacy schema

[review]


🧠 Learnings used
Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-18T01:23:18.963Z
Learning: Applies to server/routes/webhooks.ts : Implement idempotent webhook event processing by deduplicating events using KV storage with 7-day TTL for Stripe and Mercury webhook handlers

Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-18T01:23:18.963Z
Learning: Applies to server/routes/stripe.ts : For Stripe webhooks, verify signatures using `STRIPE_WEBHOOK_SECRET` before processing events in route handlers

@claude
Copy link
Copy Markdown

claude Bot commented Apr 23, 2026

Code Review — PR #99: Mercury Native Webhook + HMAC Verification

Overall this is a solid addition. The Web Crypto HMAC implementation is Workers-compatible, the dual-layer dedup (KV + DB) is the right pattern, and preserving the legacy ChittyConnect path is the correct backwards-compat call. A few things need attention before merge.


🔴 Security Issues

1. No timestamp replay protection in verifyMercurySignature

The function parses the t= timestamp from the header but never validates that it's recent. A captured valid webhook payload could be replayed after the 7-day KV TTL expires. Stripe-style convention is to reject timestamps older than 5 minutes:

```typescript
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
```

2. Signature skip silently accepts all traffic when no KV secret is present

```typescript
if (secret) {
// verify
} else {
console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
}
```

This is only safe during Mercury's registration ping. But after registration, if the KV entry is accidentally evicted or the PUT secret step was missed, all subsequent traffic is accepted unauthenticated. Consider checking MERCURY_WEBHOOK_SECRET env var as a global fallback before falling through to no-auth, and returning 503 webhook_not_configured rather than silently skipping.

3. lookupAccountByExternalId is cross-tenant without a DB-level filter

```typescript
// server/storage/system.ts
async lookupAccountByExternalId(externalId: string) {
const [row] = await this.db
.select()
.from(schema.accounts)
.where(eq(schema.accounts.externalId, externalId));
return row;
}
```

The route checks acct.tenantId === tenantId after the call, which prevents a cross-tenant bind, but the query itself pulls rows from any tenant. If two tenants share a Mercury account (unlikely but not impossible in the IT CAN BE LLC structure with shared bank accounts), the first row wins. Safer to add tenantId to the query and document the cross-tenant intent explicitly if it's really needed.


🟡 Potential Bugs

4. Race condition between KV dedup write and DB unique constraint

The sequence is:

  1. kv.get(dedupKey) → miss
  2. kv.put(dedupKey, rawBody, ...)
  3. storage.getTransactionByExternalId(...) → miss
  4. storage.createTransaction(...) ← could throw unique constraint if two concurrent identical events arrive

If step 4 throws a unique-constraint violation, the webhook returns 500 instead of the correct 200/duplicate response. Since the DB dedup is the real safety net, wrapping createTransaction in a try/catch that handles unique violations would close this gap gracefully.

5. Silent drop on unrecognized payloads

```typescript
const parsed = mercuryEventSchema.safeParse(body);
if (!parsed.success) {
console.warn('[webhook:mercury] Unrecognized payload, acking', { tenantId, body });
return c.json({ received: true }, 200);
}
```

A verification ping and a malformed real event both land here. This is acceptable, but note that the event is acknowledged and dropped with no record in the DB. If Mercury's schema evolves (new required fields), real transactions could be silently lost. Consider at least logging the Zod error details alongside the body so post-incident analysis is possible.


🟡 Code Quality

6. Hardcoded confidence strings duplicated

'0.100' and '0.700' appear twice (once in each endpoint). Pull them into named constants at the top of the file:

```typescript
const CONFIDENCE_SUSPENSE = '0.100';
const CONFIDENCE_KEYWORD_MATCH = '0.700';
```

7. Auto-creating accounts on fallback is high-blast-radius

```typescript
} else {
const created = await storage.createAccount({
tenantId,
name: 'Mercury Checking',
type: 'checking',
institution: 'Mercury',
externalId: mercuryAccountId ? mercury:${mercuryAccountId} : undefined,
});
accountId = created.id;
}
```

Creating a new account in the fallback path could lead to duplicate accounts over time (e.g., account resolution misses on the first request, then lookupAccountByExternalId works on the second). Adding a log line here is insufficient — this should at minimum emit a structured alert via ledgerLog so it's queryable. Also worth considering whether transactions should be dropped (with a 422) rather than persisted to an auto-created account, since that could make reconciliation harder.

8. Removed comments on the legacy path reduce readability

Several genuinely useful comments were stripped from /api/webhooks/mercury (e.g. the note explaining why createDb is called fresh per request, and the ChittySchema "never blocks" explanation). These aren't redundant — they explain non-obvious constraints. Worth keeping at least one-line versions.


🟢 What's Done Well

  • Web Crypto HMAC — Workers-compatible, no Node.js dependency. Correct signed-payload format (<timestamp>.<body>).
  • Dual-layer dedup — KV for speed, DB for correctness. Right pattern.
  • L0→L1 classification at ingest — correctly follows the trust path in CLAUDE.md (suggests suggestedCoaCode, never sets coa_code directly).
  • Advisory ChittySchema validation — correctly never blocks writes.
  • Legacy path preserved — backwards-compat with ChittyConnect without disruption.
  • ledgerLog on ingest — audit trail is wired correctly.

Summary

Count
🔴 Block (security) 3
🟡 Should fix 4
🟢 Good 6

The timestamp replay check (#1) and the unguarded auto-create (#7) are the most important fixes before this goes to production. The cross-tenant lookup (#3) and the silent-skip auth (#2) are close seconds. The race condition (#4) is low-probability but the fix is trivial.

Happy to pair on any of these if useful.

- Add JSON parse guard on PUT /api/webhooks/mercury/:tenantId/secret
- Truncate unrecognized payload logs to keys-only (avoid leaking financial data)

Addresses review comments from Copilot and CodeRabbit on PR #99.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 23, 2026 17:53
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Tip

For best results, initiate chat on the files or code changes.

@github-actions Here is the consolidated security evaluation for PR #99.


🔐 Security Review — PR #99: Mercury Native Webhook

1. Security Implications

🚨 Critical — Signature Verification Bypass

When no per-tenant KV secret is configured, the handler skips all HMAC validation and falls through to full transaction processing:

if (secret) {
  if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
    return c.json({ error: 'invalid_signature' }, 401);
  }
} else {
  console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
  // ← continues to transaction + account creation
}

Any unauthenticated HTTP POST to /api/webhooks/mercury/<any-uuid> can inject financial transactions and trigger auto-account creation until a secret is explicitly registered via PUT /secret.

Fix — fail closed:

if (!secret) {
  return c.json({ error: 'webhook_not_configured' }, 401);
}
if (!signatureHeader || !(await verifyMercurySignature(rawBody, signatureHeader, secret))) {
  return c.json({ error: 'invalid_signature' }, 401);
}

If Mercury's registration ping requires a 200 before a secret is known, detect the ping specifically by its payload shape (e.g., empty mergePatch) rather than bypassing all verification.


🔴 High — No Timestamp Replay-Window Validation

verifyMercurySignature extracts t=<unix_timestamp> from Mercury-Signature and includes it in the signed string but never validates it against the wall clock. The 7-day KV dedup helps for duplicate event.id values, but a captured valid payload with a novel event.id can be replayed indefinitely.

// Add after extracting `timestamp` in verifyMercurySignature:
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(now - ts) > 300) return false; // 5-minute window

🔴 High — tenantId URL Parameter Not Validated Against the Database

The tenantId path parameter is used directly in createTransaction, createAccount, and KV writes with no check that the tenant exists. Requests to arbitrary UUIDs will create phantom-tenant data.

// Add early in the handler:
const tenant = await storage.getTenant(tenantId);
if (!tenant) return c.json({ error: 'invalid_tenant' }, 400);

🟠 Medium — Cross-Tenant Scan in lookupAccountByExternalId

SystemStorage.lookupAccountByExternalId(externalId) performs a DB-wide query with no tenant filter. The post-call guard acct.tenantId === tenantId prevents data leakage but still exposes a timing oracle across tenants:

// server/storage/system.ts — add tenant scoping
async lookupAccountByExternalId(externalId: string, tenantId: string) {
  const [row] = await this.db.select().from(schema.accounts)
    .where(and(
      eq(schema.accounts.externalId, externalId),
      eq(schema.accounts.tenantId, tenantId)
    ));
  return row;
}

🟡 Low — No Request Body Size Guard

await c.req.text() runs before any size validation. A crafted oversized payload could inflate memory and KV storage. A 64 KB application-level guard before reading the body is advisable.


2. Credential Exposure Risk

Vector Current State Risk
MERCURY_WEBHOOK_SECRET env var Consumed from c.env, never logged ✅ Safe
Per-tenant KV secrets Stored as plain-text KV values ⚠️ Readable by any Worker or wrangler kv key get
PUT /secret token comparison token !== expectednon-constant-time ⚠️ Timing side-channel
KV dedup values Stores full rawBody (financial payload) 7 days ⚠️ PII/financial data in secondary store
Unrecognized payload log console.warn(..., { tenantId, body })full body emitted ⚠️ PII in Cloudflare log drain
HMAC key material Never logged ✅ Safe
PUT /secret response Returns only { stored: true, tenantId } ✅ Safe

Recommended fixes:

// 1. Constant-time token comparison for PUT /secret
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let r = 0;
  for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return r === 0;
}
if (!token || !timingSafeEqual(token, expected)) return c.json({ error: 'unauthorized' }, 401);

// 2. Store a sentinel in KV — not the full raw body
await kv.put(dedupKey, '1', { expirationTtl: 604800 });

// 3. Redact body from unrecognized-payload log
console.warn('[webhook:mercury] Unrecognized payload, acking', {
  tenantId,
  bodyKeys: Object.keys(body),
});

3. Dependency Supply Chain

No new external dependencies introduced. The implementation relies exclusively on:

  • hono, zod, drizzle-orm — already in the dependency tree
  • Web Crypto API (crypto.subtle.importKey + crypto.subtle.sign) — built into the Cloudflare Workers runtime; no third-party HMAC library added

The HMAC construction (${timestamp}.${rawBody}, SHA-256, constant-time XOR comparison) follows the same pattern used for Stripe in this codebase. Supply chain risk is low.


4. Breaking API Changes

Endpoint Change Risk
POST /api/webhooks/mercury (legacy) Input schema replaced: old Mercury envelope → normalizedEnvelopeSchema + normalizedTransactionSchema 🟠 Breaking for any ChittyConnect version still sending the old shape
POST /api/webhooks/mercury (legacy) Duplicate response code: 202; native path returns 200 ⚠️ Inconsistent — callers relying on exact status codes will diverge
POST /api/webhooks/mercury/:tenantId New endpoint — additive ✅ Non-breaking
PUT /api/webhooks/mercury/:tenantId/secret New endpoint — additive ✅ Non-breaking

Recommendation: Verify ChittyConnect's outbound payload schema matches normalizedEnvelopeSchema before merging, and align duplicate-event response codes (200 vs 202) across both paths.


Priority Summary

Priority Item
🚨 Fix before production Signature bypass — return 401 when no secret is configured
🔴 Fix before production Add 5-minute timestamp replay window in verifyMercurySignature
🔴 Fix before production Validate tenantId against the tenants table
🟠 Address soon Scope lookupAccountByExternalId to the tenant
🟠 Address soon Constant-time comparison in PUT /secret
🟠 Address soon Store '1' sentinel in KV instead of full rawBody
🟠 Address soon Redact body from unrecognized-payload log
🟡 Nice to have Body size guard before c.req.text()
🟡 Verify ChittyConnect payload compatibility with updated legacy schema

🧠 Learnings used
Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-23T17:50:57.008Z
Learning: Validate all webhook signatures using provider-specific secrets (STRIPE_WEBHOOK_SECRET, MERCURY service auth) and implement idempotent processing with KV deduplication using 7-day TTL

Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-23T17:50:57.008Z
Learning: Applies to **/server/routes/webhooks.ts : For Stripe webhook events, verify signatures using webhook.constructEvent() with STRIPE_WEBHOOK_SECRET and store raw request body as string (not parsed JSON)

Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-23T17:50:57.008Z
Learning: Implement OAuth security using HMAC-SHA256 signed state tokens from server/lib/oauth-state-edge.ts with 10-minute expiration for all third-party OAuth integrations (Wave, Stripe, Mercury)

@claude
Copy link
Copy Markdown

claude Bot commented Apr 23, 2026

Code Review — PR #99: Mercury Native Webhook with HMAC Verification

Overview

This PR introduces a tenant-scoped Mercury native webhook endpoint (POST /api/webhooks/mercury/:tenantId) with HMAC-SHA256 signature verification, per-tenant KV secret management, and auto-classification/persistence of transaction events. The legacy ChittyConnect-normalized path is preserved. The refactoring is generally clean and the architecture makes sense.


🔴 Security Issues

1. Signature skip creates an unauthenticated ingestion window

if (secret) {
  if (!signatureHeader || !(await verifyMercurySignature(...))) {
    return c.json({ error: 'invalid_signature' }, 401);
  }
} else {
  console.warn('[webhook:mercury] No secret for tenant', tenantId, '— signature verification skipped');
}

Skipping verification when no KV secret exists means any request with a known (or guessed) tenant UUID is accepted and persisted as real transactions. Tenant UUIDs are referenced in other API responses, making them guessable. The registration-ping use case doesn't justify this — Mercury's verification ping is typically an empty body (already handled: the JSON.parse catch returns 200) or a known ping event. A better approach: skip verification only if the body is empty or fails to parse as a Mercury event, not for all traffic with any valid JSON.

2. No timestamp freshness check = replay attacks

verifyMercurySignature parses t=<timestamp> from the header but never validates it. A captured valid request (with its signature) can be replayed indefinitely. Add a 5-minute window check:

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;

3. lookupAccountByExternalId is a cross-tenant query

async lookupAccountByExternalId(externalId: string) {
  const [row] = await this.db.select().from(schema.accounts)
    .where(eq(schema.accounts.externalId, externalId));
  return row;
}

The project convention (enforced throughout SystemStorage) is that all queries include a tenantId filter to maintain tenant isolation at the query layer. This method scans across all tenants, relying on the caller to check acct.tenantId === tenantId afterward. If the caller ever changes, tenant data leaks silently. Add a tenantId parameter to the method signature.


🟡 Correctness Issues

4. Auto-creating accounts races on concurrent webhooks

const created = await storage.createAccount({
  tenantId,
  name: 'Mercury Checking',
  type: 'checking',
  institution: 'Mercury',
  externalId: mercuryAccountId ? `mercury:${mercuryAccountId}` : undefined,
});

If two transaction webhooks arrive simultaneously for a tenant with no accounts, both pass the if (!accountId) guard and two accounts get created. Needs an ON CONFLICT DO NOTHING or similar upsert — or at minimum a re-query after the create so subsequent transactions land on the same account.

5. KV dedup written before DB write succeeds

await kv.put(dedupKey, rawBody, { expirationTtl: 604800 });
// ... (several operations later)
const created = await storage.createTransaction(...);

If the DB write fails after the KV entry is stored, the event is permanently deduplicated — the transaction is lost but Mercury will never successfully retry. Move the KV write to after the successful createTransaction, or use a two-phase flag (mark as processing, then done).

6. HTTP 200 vs 202 inconsistency between paths

The legacy path returns 202 for ack-only responses (no transaction, duplicate), but the new path returns 200 for the same cases. Pick one and be consistent — 200 is reasonable but the divergence is confusing when debugging with logs.


🟡 Code Quality Issues

7. Type annotation mismatch on secret parsing

let parsed: { secret?: string };
try {
  parsed = await c.req.json<{ secret: string }>();

The outer annotation makes secret optional but the generic makes it required. Let TypeScript infer the type from the Zod parse or use a consistent type, and validate with .safeParse rather than a bare try/catch so you get a proper 400 on malformed input.

8. getAccounts(tenantId) + .find() is inefficient

const accounts = await storage.getAccounts(tenantId);
const active = accounts.find((a) => a.isActive);

This fetches all accounts for the tenant to find the first active one. For tenants with many accounts this is wasteful. Consider adding a getFirstActiveAccount(tenantId) method with LIMIT 1, or scope the fallback lookup more narrowly.

9. KV dedup key namespace is shared between both paths

Both the new native path and the legacy path use webhook:mercury:<eventId> as the dedup key. Mercury native events use the event's id field; the legacy path uses eventId from the envelope. These should be namespaced differently (e.g. webhook:mercury:native:<id> vs webhook:mercury:legacy:<id>) to avoid false dedup collisions if the same string appears in both flows.


🟢 What's Good

  • The HMAC verification implementation is solid: correct use of Web Crypto (crypto.subtle), constant-time comparison for the hex bytes, and no string comparison shortcuts.
  • Using rawBody (text) for signature verification before JSON parsing is the right approach — avoids any canonicalization differences.
  • Two-layer dedup (KV + DB) with an explicit external ID is robust.
  • The advisory-only validateRow pattern (log but never block) is the right call for ingest paths.
  • Per-tenant secrets in KV is the correct architecture for Mercury's per-registration webhook model.
  • The classificationConfidence string type and the trust-level comments are consistent with the existing COA classification pattern in the codebase.

Summary

Severity Count
🔴 Security 3
🟡 Correctness / Quality 6
🟢 Positive 6

The unauthenticated ingestion window (#1) and missing timestamp validation (#2) should be addressed before merge — both are exploitable from the public internet. The cross-tenant account lookup (#3) and race on account creation (#4) are lower-urgency but worth fixing before this path sees production load.

🤖 Reviewed by Claude Code

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/webhooks.ts`:
- Line 318: The transaction-type ternary in webhooks.ts that sets type: amount
>= 0 ? 'income' : 'expense' misclassifies zero-value transactions as 'income';
change the condition to use amount > 0 so zero amounts are not treated as income
(update the expression in the function/handler that builds the transaction
object where the ternary is used, and audit any downstream logic/tests that rely
on transaction.type to ensure zero-amount behaviors are handled explicitly).
- Around line 215-251: Before calling c.req.text() and storing rawBody into KV,
validate the request size by checking the Content-Length header (or reject if
absent/untrusted) and reject requests larger than a safe threshold (e.g., 1 MB)
with a 413 response; update the handler around c.req.text(), signature
verification and the KV write (dedupKey/kv.put) to perform this early check and
avoid reading/keeping oversized bodies or putting them into KV. Ensure the logic
uses c.req.header('Content-Length') to parse and compare against the limit,
returns c.json({ error: 'payload_too_large' }, 413) when exceeded, and only then
proceeds to call c.req.text(), verifyMercurySignature, parse JSON, and
kv.put(dedupKey, rawBody,...).
- Line 249: The native webhook handler currently returns c.json({ received:
true, duplicate: true }, 200) (seen in server/routes/webhooks.ts) which is
inconsistent with the legacy path that returns 202 for duplicates; change the
duplicate-response status in the native path to 202 to match the legacy/Wave
behavior (update all native duplicate return sites, e.g., the occurrences with
the exact object { received: true, duplicate: true }), and run/update any tests
or integration checks that assert the previous 200 to expect 202.
- Around line 302-310: The current flow creates a Mercury Checking account via
storage.createAccount without externalId when mercuryAccountId is null, causing
future events with an external id to not match lookupAccountByExternalId and
possibly attach to the wrong account; change the logic so we either (A) do not
auto-create an account unless mercuryAccountId is present (i.e., early-return or
postpone creation) or (B) if you must create a placeholder, persist a flag or
placeholder externalId and ensure subsequent handlers update that record’s
externalId when mercuryAccountId arrives; implement the fix by updating the
branch that assigns accountId (the code calling storage.createAccount) and
add/update a path in the handler that receives a mercuryAccountId to find an
existing placeholder (by name/institution or a pending flag) and call
storage.updateAccount to set externalId so lookupAccountByExternalId will match,
and ensure lookupAccountByExternalId remains the primary match when
mercuryAccountId is available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5662ee8e-a02c-4391-bdc8-f252c06522d4

📥 Commits

Reviewing files that changed from the base of the PR and between 8a2926b and 4230de4.

📒 Files selected for processing (1)
  • server/routes/webhooks.ts

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
Comment thread server/routes/webhooks.ts
@chitcommit chitcommit merged commit a7345ab into main Apr 24, 2026
28 checks passed
@chitcommit chitcommit deleted the feat/mercury-native-webhooks branch April 24, 2026 08:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants