Skip to content

feat(sdk): webhook receiver hardening — auth, rate limit, provider-shape (#304) - #384

Merged
kjgbot merged 1 commit into
mainfrom
feat/webhook-receiver-hardening-304
Sep 12, 2026
Merged

kjgbot merged 1 commit into
mainfrom
feat/webhook-receiver-hardening-304

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #304. Follow-up to #333 (slice E) + #380 (admission).

Summary

  • HMAC signature verification — WEBHOOK_SECRET_<PROVIDER_UPPER> gates auth; unsigned mode retained for loopback dev per flows: event triggers via webhook + inbox watcher — SURFACE §1 harness #301. GitHub (x-hub-signature-256) + generic (x-flows-signature-256) schemes ship today.
  • Rate limiting — in-process token bucket keyed by (provider, name, source). Emits Retry-After. Cluster mode via shared store is a follow-up.
  • Provider-shape validation — canonical webhook_payload_invalid error kind.

Test plan

  • 11 new unit tests in tests/webhook-hardening.test.ts — signature refuse/accept/missing, unsigned dev mode, rate limit burst + refill + per-key isolation, provider-shape refuse
  • Existing 8 webhook.test.ts tests still pass
  • Typecheck clean

Note

Medium Risk
Changes webhook ingress behavior (401/429 paths, error renames) and auth tied to env secrets; impact is mostly loopback dev unless secrets are enabled for public ingress.

Overview
Hardens the local webhook receiver with optional HMAC auth, per-route rate limits, and a stable error for bad provider envelopes.

startWebhookServer now takes WebhookServerOptions (admittedNames, injectable env, optional rateLimit). Request handling order is tightened: loaded-flow admission still runs before body read, and rate limiting (default 20/s, burst 60) runs next, keyed by provider, name, and client address via new webhook-rate-limit helpers. When WEBHOOK_SECRET_<PROVIDER> (or WEBHOOK_SECRET_DEFAULT for raw routes) is set, webhook-signature verifies GitHub/Slack/generic headers on the raw body and returns 401 webhook_signature_invalid; with no secret, loopback dev stays unsigned.

Provider route validation failures now use webhook_payload_invalid instead of invalid_provider_event. tests/webhook-hardening.test.ts covers signatures, 429 + Retry-After, and shape rejection; it is wired into tsconfig.tests.json.

Reviewed by Cursor Bugbot for commit ad3d3eb. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 0a9e0f41-637d-499f-a81e-45266c9b0e4b


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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk/src/cli/serve-webhook.ts">

<violation number="1" location="packages/sdk/src/cli/serve-webhook.ts:93">
P2: When `ratePerSecond` is zero, this emits `Retry-After: Infinity` and `retryAfterMs: null`, neither of which is valid retry metadata. Omit the retry header and field for a non-refilling bucket, or define a finite retry policy.</violation>
</file>

<file name="packages/sdk/src/webhook-signature.ts">

<violation number="1" location="packages/sdk/src/webhook-signature.ts:31">
P1: When `WEBHOOK_SECRET_SLACK` is configured, valid Slack signatures are rejected because `slack.compute` omits Slack’s per-request timestamp. Read `X-Slack-Request-Timestamp` and verify `v0:{timestamp}:{rawBody}` (including replay-age validation), or remove Slack from `SIGNATURE_SCHEMES` until that integration exists.</violation>
</file>

<file name="packages/sdk/src/webhook-rate-limit.ts">

<violation number="1" location="packages/sdk/src/webhook-rate-limit.ts:27">
P2: Every unique route/source key remains in `buckets` forever. Because the receiver admits arbitrary route names before validation, a local client can grow this map without bound; add idle eviction or a bounded key policy.</violation>

<violation number="2" location="packages/sdk/src/webhook-rate-limit.ts:33">
P2: When a caller supplies `NaN` or `Infinity` in `RateLimitConfig`, the constructor accepts it and the bucket can reject every request with a non-finite retry value. Require finite rate and burst values before creating the limiter.</violation>

<violation number="3" location="packages/sdk/src/webhook-rate-limit.ts:68">
P2: When a generic `/raw` request and a `/providers/raw` request share a source, `keyFor` gives them the same bucket. Encode the tuple without using a sentinel string that can also be a provider name.</violation>
</file>

<file name="packages/sdk/tests/webhook-hardening.test.ts">

<violation number="1" location="packages/sdk/tests/webhook-hardening.test.ts:140">
P3: The test 'keys independently per (provider, name, source)' only varies the source address; provider ('github') and name ('pr') are identical across all three consumes, so it does not actually cover provider or name isolation. Vary those dimensions too, or rename the test to reflect that only source is covered.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// Slack's v0 scheme is HMAC(SHA256, secret) over `v0:{ts}:{body}`; a
// deployment that wants Slack signature verification must set the secret
// to include the timestamp prefix or use provider-specific integration.
return 'v0=' + createHmac('sha256', secret).update(rawBody).digest('hex');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When WEBHOOK_SECRET_SLACK is configured, valid Slack signatures are rejected because slack.compute omits Slack’s per-request timestamp. Read X-Slack-Request-Timestamp and verify v0:{timestamp}:{rawBody} (including replay-age validation), or remove Slack from SIGNATURE_SCHEMES until that integration exists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/webhook-signature.ts, line 31:

<comment>When `WEBHOOK_SECRET_SLACK` is configured, valid Slack signatures are rejected because `slack.compute` omits Slack’s per-request timestamp. Read `X-Slack-Request-Timestamp` and verify `v0:{timestamp}:{rawBody}` (including replay-age validation), or remove Slack from `SIGNATURE_SCHEMES` until that integration exists.</comment>

<file context>
@@ -0,0 +1,74 @@
+    // Slack's v0 scheme is HMAC(SHA256, secret) over `v0:{ts}:{body}`; a
+    // deployment that wants Slack signature verification must set the secret
+    // to include the timestamp prefix or use provider-specific integration.
+    return 'v0=' + createHmac('sha256', secret).update(rawBody).digest('hex');
+  },
+};
</file context>

Comment on lines +93 to +95
const retryAfterSeconds = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
response.setHeader('retry-after', String(retryAfterSeconds));
reply(response, 429, { error: 'webhook_rate_limited', retryAfterMs: decision.retryAfterMs });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When ratePerSecond is zero, this emits Retry-After: Infinity and retryAfterMs: null, neither of which is valid retry metadata. Omit the retry header and field for a non-refilling bucket, or define a finite retry policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/cli/serve-webhook.ts, line 93:

<comment>When `ratePerSecond` is zero, this emits `Retry-After: Infinity` and `retryAfterMs: null`, neither of which is valid retry metadata. Omit the retry header and field for a non-refilling bucket, or define a finite retry policy.</comment>

<file context>
@@ -53,6 +81,20 @@ export async function startWebhookServer(dataDir: string, port: number): Promise
+    const decision = limiter.consume(rateKey);
+    if (!decision.allowed) {
+      request.resume();
+      const retryAfterSeconds = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
+      response.setHeader('retry-after', String(retryAfterSeconds));
+      reply(response, 429, { error: 'webhook_rate_limited', retryAfterMs: decision.retryAfterMs });
</file context>
Suggested change
const retryAfterSeconds = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
response.setHeader('retry-after', String(retryAfterSeconds));
reply(response, 429, { error: 'webhook_rate_limited', retryAfterMs: decision.retryAfterMs });
const retryAfterSeconds = Number.isFinite(decision.retryAfterMs)
? Math.max(1, Math.ceil(decision.retryAfterMs / 1000))
: undefined;
if (retryAfterSeconds !== undefined) response.setHeader('retry-after', String(retryAfterSeconds));
reply(response, 429, { error: 'webhook_rate_limited',
...(retryAfterSeconds === undefined ? {} : { retryAfterMs: decision.retryAfterMs }) });

name: string,
sourceAddress: string,
): string {
return `${provider ?? 'raw'}:${name}:${sourceAddress}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When a generic /raw request and a /providers/raw request share a source, keyFor gives them the same bucket. Encode the tuple without using a sentinel string that can also be a provider name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/webhook-rate-limit.ts, line 68:

<comment>When a generic `/raw` request and a `/providers/raw` request share a source, `keyFor` gives them the same bucket. Encode the tuple without using a sentinel string that can also be a provider name.</comment>

<file context>
@@ -0,0 +1,69 @@
+  name: string,
+  sourceAddress: string,
+): string {
+  return `${provider ?? 'raw'}:${name}:${sourceAddress}`;
+}
</file context>
Suggested change
return `${provider ?? 'raw'}:${name}:${sourceAddress}`;
return JSON.stringify([provider ?? null, name, sourceAddress]);

}

export class TokenBucketLimiter {
private readonly buckets = new Map<string, Bucket>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Every unique route/source key remains in buckets forever. Because the receiver admits arbitrary route names before validation, a local client can grow this map without bound; add idle eviction or a bounded key policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/webhook-rate-limit.ts, line 27:

<comment>Every unique route/source key remains in `buckets` forever. Because the receiver admits arbitrary route names before validation, a local client can grow this map without bound; add idle eviction or a bounded key policy.</comment>

<file context>
@@ -0,0 +1,69 @@
+}
+
+export class TokenBucketLimiter {
+  private readonly buckets = new Map<string, Bucket>();
+
+  constructor(
</file context>

private readonly config: RateLimitConfig,
private readonly clock: () => number = Date.now,
) {
if (config.burst <= 0 || config.ratePerSecond < 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When a caller supplies NaN or Infinity in RateLimitConfig, the constructor accepts it and the bucket can reject every request with a non-finite retry value. Require finite rate and burst values before creating the limiter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/webhook-rate-limit.ts, line 33:

<comment>When a caller supplies `NaN` or `Infinity` in `RateLimitConfig`, the constructor accepts it and the bucket can reject every request with a non-finite retry value. Require finite rate and burst values before creating the limiter.</comment>

<file context>
@@ -0,0 +1,69 @@
+    private readonly config: RateLimitConfig,
+    private readonly clock: () => number = Date.now,
+  ) {
+    if (config.burst <= 0 || config.ratePerSecond < 0) {
+      throw new Error('rate limiter: burst > 0 and ratePerSecond >= 0 required');
+    }
</file context>


it('keys independently per (provider, name, source)', () => {
const limiter = new TokenBucketLimiter({ ratePerSecond: 0, burst: 1 });
expect(limiter.consume(keyFor('github', 'pr', '10.0.0.1')).allowed).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The test 'keys independently per (provider, name, source)' only varies the source address; provider ('github') and name ('pr') are identical across all three consumes, so it does not actually cover provider or name isolation. Vary those dimensions too, or rename the test to reflect that only source is covered.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/tests/webhook-hardening.test.ts, line 140:

<comment>The test 'keys independently per (provider, name, source)' only varies the source address; provider ('github') and name ('pr') are identical across all three consumes, so it does not actually cover provider or name isolation. Vary those dimensions too, or rename the test to reflect that only source is covered.</comment>

<file context>
@@ -0,0 +1,144 @@
+
+  it('keys independently per (provider, name, source)', () => {
+    const limiter = new TokenBucketLimiter({ ratePerSecond: 0, burst: 1 });
+    expect(limiter.consume(keyFor('github', 'pr', '10.0.0.1')).allowed).toBe(true);
+    expect(limiter.consume(keyFor('github', 'pr', '10.0.0.1')).allowed).toBe(false);
+    expect(limiter.consume(keyFor('github', 'pr', '10.0.0.2')).allowed).toBe(true);
</file context>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4fe4f6e. Configure here.

// to include the timestamp prefix or use provider-specific integration.
return 'v0=' + createHmac('sha256', secret).update(rawBody).digest('hex');
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Slack signature scheme is incorrect

Medium Severity

The slack scheme is registered in SIGNATURE_SCHEMES, so WEBHOOK_SECRET_SLACK selects it, but compute HMACs only the raw body and never reads x-slack-request-timestamp. Slack signs v0:{timestamp}:{body}, so enabling that secret rejects real Slack deliveries. Putting the timestamp into the static secret cannot work because the timestamp changes per request.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4fe4f6e. Configure here.

const missingTokens = cost - bucket.tokens;
const retryAfterMs = this.config.ratePerSecond > 0
? Math.ceil((missingTokens / this.config.ratePerSecond) * 1000)
: Number.POSITIVE_INFINITY;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Zero rate emits invalid Retry-After

Low Severity

When ratePerSecond is 0, consume returns retryAfterMs of Infinity. The receiver then sends retry-after: Infinity, which is not a valid delay-seconds value, and JSON.stringify turns retryAfterMs into null. The HTTP rate-limit test uses this configuration, so clients lose a usable retry hint.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4fe4f6e. Configure here.

@kjgbot

kjgbot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

maintainability lens — FAIL

PR #384 maintainability review

Lens: could a stranger read this in six months and change it safely? I focused on unclear boundaries, implicit contracts, silent renames, and tests that would not fail if the behavior broke.

Blockers

B1 — Slack scheme is registered as supported but structurally cannot verify a real Slack signature

packages/sdk/src/webhook-signature.ts:25-33. Slack's v0 scheme signs "v0:{ts}:{body}" using X-Slack-Request-Timestamp; the timestamp is per-request and MUST be checked against replay. The SignatureScheme.compute(rawBody, secret) contract has no access to headers, so the registered slack scheme will never verify a real Slack POST. The comment admits this ("must set the secret to include the timestamp prefix") — but a static secret cannot encode a per-request timestamp, so the workaround is not a workaround. A future reader will see SIGNATURE_SCHEMES.slack present and green tests and believe Slack is supported. The test file exercises github only, so the abstraction breaks silently. Either widen the scheme contract to compute(rawBody, secret, headers) and implement Slack correctly, or drop slack from the registry until it is real.

B2 — 429 emits Retry-After: Infinity when ratePerSecond === 0, and the test asserts only truthiness

webhook-rate-limit.ts:51-54 returns retryAfterMs = Number.POSITIVE_INFINITY when ratePerSecond === 0. serve-webhook.ts:93 then does Math.ceil(Infinity / 1000) → Infinity, and String(Infinity) → "Infinity". RFC 9110 §10.2.3 requires delta-seconds or HTTP-date; "Infinity" is neither. The rate-limit test at tests/webhook-hardening.test.ts:79-84 uses exactly this config (ratePerSecond: 0, burst: 2) and asserts expect(limited.headers.get('retry-after')).toBeTruthy() — which "Infinity" satisfies. The behavior could regress from valid to invalid without failing the suite.

Concerns

C1 — webhook_payload_invalid is a silent rename of invalid_provider_event

serve-webhook.ts:138-140. The error taxonomy changed from invalid_provider_event to webhook_payload_invalid with only an inline comment. Any downstream code, run-cancel handler, or ops dashboard matching on the old string breaks quietly. No callers audit is visible in the diff.

C2 — providerSecret normalization collapses distinct webhook names to the same secret

serve-webhook.ts:46-50. provider.toUpperCase().replace(/[^A-Z0-9]/g, '_') maps both github and git-hub to WEBHOOK_SECRET_GITHUB. The NAME regex allows _ and -, so two routes can collide onto one secret without any warning at bind time. The "provider" here is the URL segment, not a curated ID.

C3 — TokenBucketLimiter.buckets grows without bound

webhook-rate-limit.ts:26-53. Keys include sourceAddress, so any change in remote peer allocates a new entry that is never evicted. The class comment defers cluster deployments to a follow-up, but the local receiver still keys by remote address and has no cap or LRU. No test covers the bound.

Notes

  • verifySignature at webhook-signature.ts:65-67 compares supplied.length !== expected.length before stripping the prefix, then strips any [^=]+= prefix — an unknown algorithm like sha512=… of matching length would pass the length gate and get stripped by pattern; guard on the concrete scheme prefix instead.
  • The removed TODO #301 comment did name public-ingress and cloud-mount as future scope; that context now lives only in git history.

REVIEW_FAILED

@kjgbot

kjgbot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

history lens — PASS

Blockers: none under the HISTORY lens. Reviewed the supplied diff, commit 4fe4f6ec, the requested 40-commit history, AGENTS.md, RFC-0001, and the operational documents.

The receiver changes in serve-webhook.ts, lines 108–143 add optional authentication before parsing and retain provider-envelope validation. I found no evidence that they restore a safeguard-removal pattern recorded in DRIVE-LOG. Unsigned operation predates this PR; preserving the loopback development posture is not a newly introduced fail-open regression.

The additions remain in the SDK and introduce no kernel step types, provider dependencies inside Rust, or changes to journal authority. I found no new contradiction with a settled RFC decision. The shared-store deferral in webhook-rate-limit.ts, lines 1–7 is explicitly documented and is not a blocker.

Concern: webhook-signature.ts, lines 25–44 registers Slack while hashing only rawBody, despite its own comment specifying v0:{ts}:{body}. Treat this branch as unfinished and clarify its availability. The commit explicitly claims GitHub and generic schemes; this discrepancy does not establish a false Slack-support claim in the commit message or a historically repeated regression.

Notes: The commit’s “11 tests” statement matches the declaration count. Literal command and captured output:

git show 4fe4f6ec:packages/sdk/tests/webhook-hardening.test.ts | rg -c "^[[:space:]]*it\("
11

That establishes test count only. I did not execute tests or typechecking, so the PR body’s passing-test assertions remain unverified here, rather than disproven. The older gate brief in ops/NEXT.md does not block this diff.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

structure lens — MISSING

@kjgbot

kjgbot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

🎯 review-swarm: FAILED (M:fail H:pass S:missing)

Lens transcripts posted as sibling comments above.

…ape (#304)

- HMAC signature verification: per-provider `WEBHOOK_SECRET_<UPPER>` env
  gates authentication; unsigned mode retained for local development. GitHub
  `x-hub-signature-256` and generic `x-flows-signature-256` schemes ship
  today. Refusal: `webhook_signature_invalid`.
- Rate limiting: in-process token bucket keyed by (provider, name, source).
  Refusal: `webhook_rate_limited` with `retry-after` header. Cluster mode
  will need a shared store; taxonomy stays the same.
- Provider-shape validation: reuses `providerInboxEvent` but the emitted
  error is now the canonical `webhook_payload_invalid`.

Files:
- packages/sdk/src/webhook-signature.ts (new)
- packages/sdk/src/webhook-rate-limit.ts (new)
- packages/sdk/src/cli/serve-webhook.ts
- packages/sdk/tests/webhook-hardening.test.ts (new, 11 tests)
- packages/sdk/tsconfig.tests.json

Closes #304.

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

Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82

Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82
@kjgbot
kjgbot force-pushed the feat/webhook-receiver-hardening-304 branch from 4fe4f6e to ad3d3eb Compare September 12, 2026 21:19
@kjgbot
kjgbot merged commit d8def5d into main Sep 12, 2026
8 of 10 checks passed
@kjgbot
kjgbot deleted the feat/webhook-receiver-hardening-304 branch September 12, 2026 21:48
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.

webhook receiver follow-ups: authentication, rate limiting and provider reconciliation (#301)

1 participant