Skip to content

Security hardening: password-hash upgrade path at the Workers PBKDF2 cap, secret-store AAD/versioning, prod rate-limiter fail-closed, audit fixes, SECURITY.md - #1382

Merged
kentcdodds merged 5 commits into
mainfrom
devin/1786429027-security-hardening
Aug 11, 2026
Merged

kentcdodds merged 5 commits into
mainfrom
devin/1786429027-security-hardening

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Intent

First batch of security hardening from the full-repo review: put password hashing at the strongest setting the Workers runtime allows with a transparent upgrade path, bind secret-store ciphertexts to their owner identity, close the "production silently falls back to the D1 auth rate limiter" gap, remediate npm audit findings, and add a disclosure policy.

Summary

  • Password hashing (packages/shared/src/password-hash.ts): generation stays at PBKDF2-SHA256 100k iterations — preview testing proved Cloudflare's production runtime rejects PBKDF2 above 100,000 iterations (deriveBits throws NotSupportedError; local workerd does not enforce the cap, so unit tests/local e2e can't catch it). The original 600k raise 500ed every auth request on the preview and was reverted. What this PR adds instead: verification rejects over-cap iteration counts cleanly before deriving, passwordHashNeedsUpgrade + upgradePasswordHashIfNeeded transparently rehash lower-strength hashes after successful login via a compare-and-swap (app/handlers/auth.ts, oauth-handlers.ts) so the setting can be raised without a migration if the platform cap lifts, a rehash failure never blocks a valid login, and a workers-pool test covers the hash paths in the Workers runtime.
  • Secret-store crypto (packages/worker/src/mcp/secrets/crypto.ts): new v2.<iv>.<ct> ciphertext format with AES-GCM AAD kody.v2|<purpose>|<context>, where context is user:<userId> for user secrets and app:<slug> for platform OAuth client secrets. A ciphertext moved to another user's row no longer decrypts. Legacy two-part ciphertexts keep decrypting (no AAD) and upgrade to v2 on next write; unknown versions and tampering reject.
    • renamePlatformOauthApp now decrypts under the old slug and re-encrypts under the new slug so carried secrets stay decryptable after a rename.
  • Auth rate limiter fails closed in production (app/env.ts): env validation throws when SENTRY_ENVIRONMENT === 'production' and AUTH_RATE_LIMITER is missing, so the D1 fallback can never silently become the production limiter. Local/preview/test/self-hosted keep the D1 fallback.
  • MCP dual-lane retirement criterion (decision 0005): legacy lane retires when trailing-30-day legacy traffic is <1% of /mcp AND no legacy client bucket (including blank/unnamed) has >100 requests, via the weighted Analytics Engine query — mechanical readout instead of judgment. Retirement includes a D1 migration dropping mcp_agent_sessions.
  • npm audit: 0 vulnerabilities (was 13), via targeted updates + overrides (brace-expansion, undici, etc.). No Dependabot/Renovate added.
  • Docs: root SECURITY.md (private advisory reporting + email fallback); docs/contributing/security.md documents the CSRF posture (SameSite=Lax + JSON content types, no tokens) as an explicit invariant, the v2/AAD ciphertext format and its owner-binding boundary (only v2 binds; legacy upgrades on write), the password scheme and the platform iteration cap, and the production rate-limiter guard.

Testing

  • Preview-deploy manual verification caught the PBKDF2 cap: every POST /auth 500ed at 600k on kody-pr-1382.kody-a99.workers.dev while the baseline preview returned 401 for the same payload; fixed and covered by a new password-hash.workers.test.ts.
  • Focused suites: password-hash (node + workers pool), crypto (context mismatch, tamper, unknown version, legacy decrypt, slug binding), platform-apps rename, rate-limit, handler env guard — all passing.
  • Full npm run validate (2027 unit tests, 8 e2e, MCP e2e) passing locally.
  • npm audit and npm audit --omit=dev: 0 vulnerabilities.

System changes

Touches authentication (login rehash machinery + over-cap rejection), secret storage (ciphertext format change, backward compatible), and env validation (new production fail-closed). No migrations; no data rewritten at rest — legacy ciphertexts/hashes upgrade lazily on write/login.

Link to Devin session: https://app.devin.ai/sessions/b5bf26745b254f289a40e061348af106
Requested by: @kentcdodds

devin-ai-integration Bot and others added 3 commits August 11, 2026 06:27
…ersioning, audit fixes, SECURITY.md

Co-Authored-By: Kent C. Dodds <me@kentcdodds.com>
…e retirement criterion

Co-Authored-By: Kent C. Dodds <me@kentcdodds.com>
Co-Authored-By: Kent C. Dodds <me@kentcdodds.com>
@kentcdodds kentcdodds self-assigned this Aug 11, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@github-actions

github-actions Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-1382.kody-a99.workers.dev

Worker: kody-pr-1382
D1: kody-pr-1382-db
KV: kody-pr-1382-oauth-kv

Mocks:

@kentcdodds

Copy link
Copy Markdown
Owner

bugbot review

@kentcdodds

Copy link
Copy Markdown
Owner

@coderabbitai review

@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run — GitHub account mismatch

The GitHub account linked to your Cursor account does not match the PR author.

Please ensure you're using the correct GitHub account, or run Bugbot from a team that covers this repository.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds context-bound encryption for user and platform secrets, transparent password-hash upgrades, production authentication-binding validation, security documentation, MCP retirement criteria, and a dependency override.

Changes

Security hardening

Layer / File(s) Summary
Context-bound encryption
packages/worker/src/mcp/secrets/crypto.ts, packages/worker/src/mcp/secrets/crypto.node.test.ts
AES-GCM encryption now emits versioned payloads with authenticated context. Legacy payloads remain decryptable.
Secret persistence and OAuth rebinding
packages/worker/src/mcp/secrets/service.ts, packages/worker/src/remote-connector/settings-service.ts, packages/worker/src/integrations/platform-apps.ts, packages/worker/src/app/handlers/admin-platform-integrations.ts, packages/worker/src/mcp/capabilities/admin/admin-platform-oauth-app-save.ts, packages/worker/src/integrations/platform-apps.node.test.ts
User and OAuth secrets now use identity-derived contexts. OAuth secrets are rebound when app slugs change.
Password-hash generation and login upgrades
packages/shared/src/password-hash.ts, packages/shared/src/password-hash.node.test.ts, packages/worker/src/password-upgrade.ts, packages/worker/src/app/handlers/auth.ts, packages/worker/src/oauth-handlers.ts
PBKDF2 generation uses 600,000 iterations. Successful logins upgrade lower-iteration hashes without blocking authentication.
Production authentication safeguards
packages/worker/src/app/env.ts, packages/worker/src/app/handler.node.test.ts, SECURITY.md, docs/contributing/security.md
Production validation requires AUTH_RATE_LIMITER. Security documentation records CSRF, encryption, password, rate-limit, and reporting requirements.

MCP retirement policy

Layer / File(s) Summary
Legacy MCP lane retirement criteria
docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md
Legacy retirement requires a 30-day traffic threshold and coordinated removal of the legacy lane and session infrastructure.

Dependency override

Layer / File(s) Summary
brace-expansion override
package.json
Adds a ^5.0.9 override for brace-expansion.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SecretService
  participant Crypto as crypto.ts
  User->>SecretService: Save or resolve secret
  SecretService->>Crypto: Encrypt or decrypt with userSecretContext
  Crypto-->>SecretService: Context-bound value
  SecretService-->>User: Return result
Loading
sequenceDiagram
  participant Admin
  participant PlatformApps as platform-apps.ts
  participant Crypto as crypto.ts
  Admin->>PlatformApps: Rename OAuth app
  PlatformApps->>Crypto: Decrypt with old slug context
  Crypto-->>PlatformApps: Return client secret
  PlatformApps->>Crypto: Encrypt with new slug context
  Crypto-->>PlatformApps: Return rebound ciphertext
  PlatformApps-->>Admin: Store renamed app
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: kentcdodds

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title accurately summarizes the PR's main security hardening changes, although it lists several related changes.
Description check ✅ Passed The description includes all required sections and provides clear intent, implementation details, testing evidence, and system impact.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1786429027-security-hardening

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🧹 Nitpick comments (4)
packages/worker/src/mcp/secrets/crypto.node.test.ts (1)

43-72: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding the platform OAuth context case.

The suite covers userSecretContext binding well. It does not cover platformOauthAppContext. A test that encrypts under app:one and fails to decrypt under app:two would lock in the slug-binding invariant that renamePlatformOauthApp depends on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/mcp/secrets/crypto.node.test.ts` around lines 43 - 72,
Extend the v2 context-binding test around encryptSecretValue and
decryptSecretValue to cover platformOauthAppContext: encrypt with app:one, then
assert decryption with app:two rejects with the existing unable-to-decrypt
error. Preserve the current user-context, tampering, and unknown-version
assertions.
packages/worker/src/mcp/secrets/crypto.ts (2)

113-128: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider removing the default empty context.

encryptStringWithPurpose and decryptStringWithPurpose default context to ''. Call sites that forget to pass a context silently produce unbound ciphertext, and the compiler does not flag it. If every current caller has a natural identity to bind, make context required so new call sites must decide explicitly. If some callers are genuinely identity-free (for example, stateless cookie payloads), keep the default and document that choice at the declaration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/mcp/secrets/crypto.ts` around lines 113 - 128, Review all
callers of encryptStringWithPurpose and decryptStringWithPurpose, then make
context required if each caller has a natural identity to bind, preserving the
existing context values. If any caller is intentionally identity-free, retain
the default empty context and document that contract at both declarations.

94-109: 🔒 Security & Privacy | 🔵 Trivial | 🏗️ Heavy lift

Confirm the plan to retire the legacy no-AAD branch.

The two-part branch decrypts without AAD, so a legacy ciphertext stays swappable between rows until it is rewritten. The comment at Line 13 states that legacy payloads upgrade "whenever the value is re-encrypted on write", but no read path re-encrypts. A stored secret that a user never re-saves keeps the weaker binding indefinitely.

Consider one of these follow-ups:

  • Add an upgrade-on-read step that re-encrypts with the v2 format after a successful legacy decrypt.
  • Run a one-time backfill, then delete the two-part branch.

Either option gives a concrete end date for the compatibility path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/mcp/secrets/crypto.ts` around lines 94 - 109, Update the
legacy two-part handling in the decryption flow to provide a concrete migration
path: after successful decryption, re-encrypt the plaintext using the current
AAD-bound format and persist the upgraded payload, or remove this compatibility
branch after implementing an equivalent one-time backfill. Ensure the legacy
payload is not left indefinitely without AAD binding.
packages/worker/src/integrations/platform-apps.ts (1)

385-390: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the existing row accessor instead of inline SQL.

getPlatformOauthAppRowBySlug already returns the row including client_secret_encrypted; Line 494 uses it for exactly this column. Calling it here removes the inline SELECT and keeps the table-column knowledge in one place.

♻️ Proposed refactor
-	const secretRow = await input.db
-		.prepare(
-			`SELECT client_secret_encrypted FROM platform_oauth_apps WHERE slug = ?`,
-		)
-		.bind(slug)
-		.first<{ client_secret_encrypted: string | null }>()
+	const secretRow = await getPlatformOauthAppRowBySlug({ db: input.db, slug })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/integrations/platform-apps.ts` around lines 385 - 390,
Replace the inline SQL query in the surrounding function with the existing
getPlatformOauthAppRowBySlug accessor, then read client_secret_encrypted from
its returned row while preserving the current null handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md`:
- Around line 64-66: Update the second retirement criterion in the dual-lane
migration decision so blank or unknown legacy client identities are not
classified as noise. Use a stable server-derived identity for such requests, or
explicitly make any blank/unknown bucket block retirement when it exceeds the
threshold; preserve the existing treatment of named clients and the 100-request
limit.
- Around line 61-64: Update criterion 1 in the migration decision to use a
denominator matching the metric’s scope: total authenticated, verified,
non-suspended, instrumented `/mcp` requests. Alternatively, add and reference a
metric covering every `/mcp` request; do not describe the existing scoped metric
as total route traffic.
- Around line 68-70: Update the retirement plan to add a D1 migration that drops
the mcp_agent_sessions table when the McpAgent lane is removed, and remove all
consumers of that table. Keep the owner check in place until the existing
session purge path is removed, then retire it with the purge path and MCP_OBJECT
changes.

In `@docs/contributing/security.md`:
- Around line 316-317: Update the security documentation around resolveSecret to
explicitly document that legacy unversioned AES-GCM ciphertext lacks owner
binding and may decrypt when copied to another user's row, including that
metadata-only writes preserve it. State that only v2 provides owner binding, or
direct implementation to reject legacy payloads unless they are owner-bound.

In `@packages/worker/src/password-upgrade.ts`:
- Around line 19-24: Update the password upgrade helper in
packages/worker/src/password-upgrade.ts:19-24 to perform an atomic conditional
update matching both userId and the original storedHash, and ignore zero-row
results. In packages/worker/src/app/handlers/auth.ts:506-515 and
packages/worker/src/oauth-handlers.ts:856-865, retain these call sites only
after the helper provides that conditional-write protection; no direct changes
are required there.

---

Nitpick comments:
In `@packages/worker/src/integrations/platform-apps.ts`:
- Around line 385-390: Replace the inline SQL query in the surrounding function
with the existing getPlatformOauthAppRowBySlug accessor, then read
client_secret_encrypted from its returned row while preserving the current null
handling.

In `@packages/worker/src/mcp/secrets/crypto.node.test.ts`:
- Around line 43-72: Extend the v2 context-binding test around
encryptSecretValue and decryptSecretValue to cover platformOauthAppContext:
encrypt with app:one, then assert decryption with app:two rejects with the
existing unable-to-decrypt error. Preserve the current user-context, tampering,
and unknown-version assertions.

In `@packages/worker/src/mcp/secrets/crypto.ts`:
- Around line 113-128: Review all callers of encryptStringWithPurpose and
decryptStringWithPurpose, then make context required if each caller has a
natural identity to bind, preserving the existing context values. If any caller
is intentionally identity-free, retain the default empty context and document
that contract at both declarations.
- Around line 94-109: Update the legacy two-part handling in the decryption flow
to provide a concrete migration path: after successful decryption, re-encrypt
the plaintext using the current AAD-bound format and persist the upgraded
payload, or remove this compatibility branch after implementing an equivalent
one-time backfill. Ensure the legacy payload is not left indefinitely without
AAD binding.
🪄 Autofix

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 Plus

Run ID: 4b9202ef-c42c-4df6-b683-b72fe4771415

📥 Commits

Reviewing files that changed from the base of the PR and between ddf9e64 and 23cded7.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • SECURITY.md
  • docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md
  • docs/contributing/security.md
  • package.json
  • packages/shared/src/password-hash.node.test.ts
  • packages/shared/src/password-hash.ts
  • packages/worker/src/app/env.ts
  • packages/worker/src/app/handler.node.test.ts
  • packages/worker/src/app/handlers/admin-platform-integrations.ts
  • packages/worker/src/app/handlers/auth.ts
  • packages/worker/src/integrations/platform-apps.node.test.ts
  • packages/worker/src/integrations/platform-apps.ts
  • packages/worker/src/mcp/capabilities/admin/admin-platform-oauth-app-save.ts
  • packages/worker/src/mcp/secrets/crypto.node.test.ts
  • packages/worker/src/mcp/secrets/crypto.ts
  • packages/worker/src/mcp/secrets/service.ts
  • packages/worker/src/oauth-handlers.ts
  • packages/worker/src/password-upgrade.ts
  • packages/worker/src/remote-connector/settings-service.ts

Comment on lines +61 to +64
Retire the legacy lane when **both** hold for the window:

1. legacy-lane requests are **< 1% of total `/mcp` requests**, and
2. **no single `client_name` accounts for more than 100 legacy requests** (a

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- decision file ---'
sed -n '1,120p' docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md
printf '%s\n' '--- producer candidates ---'
fd -i 'protocol-metrics' .
printf '%s\n' '--- metric references ---'
rg -n -C 3 'protocol-metrics|Analytics Engine|legacy.?lane|client_name|mcp' packages/worker docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md 2>/dev/null | head -n 300

Repository: kentcdodds/kody

Length of output: 29749


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- protocol metrics outline ---'
ast-grep outline packages/worker/src/mcp/protocol-metrics.ts
printf '%s\n' '--- protocol metrics source ---'
cat -n packages/worker/src/mcp/protocol-metrics.ts
printf '%s\n' '--- protocol metrics tests ---'
cat -n packages/worker/src/mcp/protocol-metrics.node.test.ts
printf '%s\n' '--- route instrumentation and auth flow references ---'
rg -n -C 5 'recordMcp|MCP_PROTOCOL_EVENTS|writeDataPoint|isLegacyRequest|protocol.?metric|authenticate|authenticated' packages/worker/src/mcp packages/worker/src | head -n 500

Repository: kentcdodds/kody

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- metric call sites ---'
rg -n -C 12 'recordMcpProtocolEvent|classifyMcpProtocolRequest' packages/worker/src --glob '!**/*.test.*'
printf '%s\n' '--- MCP route handlers ---'
rg -l 'handleMcpRequest|createMcpHandler|McpAgent' packages/worker/src/mcp packages/worker/src | head -n 80
printf '%s\n' '--- focused handler definitions ---'
rg -n -C 20 'function handleMcpRequest|export .*handleMcpRequest|async function handleMcpRequest|createMcpHandler' packages/worker/src/mcp packages/worker/src | head -n 300

Repository: kentcdodds/kody

Length of output: 32366


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- authenticated request boundary ---'
sed -n '164,285p' packages/worker/src/mcp-auth.ts
printf '%s\n' '--- route dispatch to authentication ---'
rg -n -C 8 'handleMcpRequest\(' packages/worker/src/index.ts packages/worker/src/mcp/index.ts packages/worker/src
printf '%s\n' '--- exact dataset configuration ---'
sed -n '400,420p' packages/worker/wrangler.jsonc
sed -n '644,660p' packages/worker/wrangler.jsonc
printf '%s\n' '--- read-only source invariant probe ---'
python3 - <<'PY'
from pathlib import Path
source = Path("packages/worker/src/mcp-auth.ts").read_text()
metrics = Path("packages/worker/src/mcp/protocol-metrics.ts").read_text()
decision = Path("docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md").read_text()

classify = source.index("const classification = await classifyMcpProtocolRequest(request)")
auth_boundary = source.index("const token = await", source.index("export async function handleMcpRequest"))
write = source.index("recordMcpProtocolEvent(env", classify)
lease = source.index("return await withAccountWriteLease", write)
query = decision[decision.index("SELECT blob1 AS lane"):decision.index("```", decision.index("SELECT blob1 AS lane"))]
print("metric_write_after_auth_boundary:", auth_boundary < classify)
print("metric_write_before_lease:", write < lease)
print("query_has_total_aggregate:", "SUM(_sample_interval)" in query and "GROUP BY" not in query)
print("query_groups_by_lane_and_client:", "GROUP BY lane, client_name" in query)
print("producer_counts_one_event:", "doubles: [1]" in metrics)
PY

Repository: kentcdodds/kody

Length of output: 9533


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

auth = Path("packages/worker/src/mcp-auth.ts").read_text()
metrics = Path("packages/worker/src/mcp/protocol-metrics.ts").read_text()
decision = Path("docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md").read_text()

auth_start = auth.index("export async function handleMcpRequest")
token_check = auth.index("const tokenSummary = await helpers.unwrapToken", auth_start)
classification = auth.index("const classification = await classifyMcpProtocolRequest(request)", auth_start)
metric_write = auth.index("recordMcpProtocolEvent(env", classification)

query_start = decision.index("SELECT blob1 AS lane")
query_end = decision.index("```", query_start)
query = decision[query_start:query_end]

print("metrics_after_token_resolution:", token_check < classification)
print("metrics_after_authentication_checks:", "if (!authContext)" in auth[:classification] and "if (!authContext.emailVerified)" in auth[:classification] and "if (authContext.suspended)" in auth[:classification])
print("metric_write_after_classification:", classification < metric_write)
print("query_aggregates_recorded_events_only:", "FROM kody_mcp_protocol_events" in query)
print("query_groups_by_lane_and_client:", "GROUP BY lane, client_name" in query)
print("query_has_explicit_total_mcp_denominator:", "total" in query.lower() and "mcp" in query.lower())
print("producer_emits_one_weighted_event:", "doubles: [1]" in metrics and "writeDataPoint" in metrics)
PY

Repository: kentcdodds/kody

Length of output: 442


Align the denominator with the recorded metric scope.

The metric records only authenticated, verified, non-suspended requests. The query therefore does not represent total /mcp route traffic. Define criterion 1 using total authenticated, instrumented /mcp requests, or add a metric for every /mcp request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md` around
lines 61 - 64, Update criterion 1 in the migration decision to use a denominator
matching the metric’s scope: total authenticated, verified, non-suspended,
instrumented `/mcp` requests. Alternatively, add and reference a metric covering
every `/mcp` request; do not describe the existing scoped metric as total route
traffic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 034bf62 — the criterion now uses total instrumented /mcp requests as the denominator and explains why that population is the right one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

Comment on lines +64 to +66
2. **no single `client_name` accounts for more than 100 legacy requests** (a
named client above that is a real installation that would break, not noise;
unnamed/blank clients count as noise).

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not treat blank client identities as noise.

packages/worker/src/mcp/protocol-metrics.node.test.ts shows supported legacy requests with clientName: '', including header-only tools/call, http:GET, and http:DELETE requests. A real legacy client can therefore exceed 100 requests in the blank bucket while the two retirement criteria still pass. Use a stable server-derived identity, or make blank and unknown legacy traffic block retirement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md` around
lines 64 - 66, Update the second retirement criterion in the dual-lane migration
decision so blank or unknown legacy client identities are not classified as
noise. Use a stable server-derived identity for such requests, or explicitly
make any blank/unknown bucket block retirement when it exceeds the threshold;
preserve the existing treatment of named clients and the 100-request limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 034bf62 — the blank/unnamed client_name bucket now explicitly counts as a bucket (header-only legacy clients report no name), not noise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

Comment on lines +68 to +70
When both hold, remove the `McpAgent` lane, the `mcp_agent_sessions` registry,
the `MCP_OBJECT` Durable Object, and the session purge path in one change. If a
later reading fails the criterion again before the removal ships, reset the

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md' \
  'packages/worker/src/mcp/session-registry.ts' \
  'packages/worker/src/mcp/protocol-metrics.ts' \
  'packages/worker/src/mcp/*' \
  | sed -n '1,120p'

printf '%s\n' '--- decision excerpt ---'
sed -n '35,90p' docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md

printf '%s\n' '--- session registry ---'
sed -n '1,130p' packages/worker/src/mcp/session-registry.ts

printf '%s\n' '--- protocol metrics ---'
sed -n '1,220p' packages/worker/src/mcp/protocol-metrics.ts

Repository: kentcdodds/kody

Length of output: 17287


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all legacy-session references ---'
rg -n -C 3 \
  'mcp_agent_sessions|purgePersistedMcpAgentSession|listMcpAgentSessionsForUser|MCP_OBJECT|McpAgent' \
  --glob '!node_modules' --glob '!dist' --glob '!build' \
  packages docs migrations 2>/dev/null | sed -n '1,260p'

printf '%s\n' '--- relevant worker files ---'
fd -i -t f 'mcp|migration|wrangler' packages/worker | \
  rg 'session|durable|migration|wrangler|mcp' | sed -n '1,220p'

printf '%s\n' '--- database schema references ---'
rg -n -C 4 'CREATE TABLE[^;]*mcp_agent_sessions|mcp_agent_sessions' \
  --glob '*.sql' --glob '*.ts' --glob '*.md' . | sed -n '1,240p'

Repository: kentcdodds/kody

Length of output: 41622


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- account deletion implementation ---'
ast-grep outline packages/worker/src/app/account-deletion.ts
rg -n -C 8 \
  'listMcpAgentSessionsForUser|purgeForAccountDeletion|purgePersistedMcpAgentSession|mcp_agent_sessions|MCP_OBJECT' \
  packages/worker/src/app/account-deletion.ts packages/worker/src/mcp packages/worker/src/index.ts packages/worker/src/mcp-auth.ts \
  | sed -n '1,300p'

printf '%s\n' '--- MCP Durable Object implementation ---'
fd -i -t f 'index.ts' packages/worker/src/mcp --exec sh -c '
  for f do
    if rg -q "class MCP|purgeForAccountDeletion|McpAgent" "$f"; then
      printf "%s\n" "--- $f ---"
      ast-grep outline "$f"
      rg -n -C 8 "class MCP|purgeForAccountDeletion|purgePersistedMcpAgentSession|McpAgent" "$f"
    fi
  done
' sh

printf '%s\n' '--- Durable Object migration/config declarations ---'
rg -n -C 8 \
  'MCP|migrations|new_sqlite_classes|deleted_classes|Analytics Engine|MCP_PROTOCOL_EVENTS' \
  packages/worker/wrangler.jsonc packages/worker/worker-configuration.d.ts packages/worker/src \
  | sed -n '1,300p'

Repository: kentcdodds/kody

Length of output: 42728


🌐 Web query:

Cloudflare Durable Objects delete existing object storage when a Durable Object class or namespace binding is removed, and recommended cleanup before migration

💡 Result:

Yes, deleting a Durable Object class permanently removes its namespace and all associated stored data [1]. To safely retire a Durable Object class, you must follow the migration process for declarative exports (or the legacy migration flow for older projects) [1][2]. Simply removing the binding from your configuration file is not sufficient and will generally prevent the deployment from succeeding if the class is still defined in your code or referenced in other ways [1][3]. Recommended process for retiring/deleting a Durable Object class: 1. Remove references: Ensure your Worker code no longer references the Durable Object class [1]. 2. Update configuration: In your Wrangler configuration file, change the class entry in the exports map to a deleted tombstone [1][4]. { "exports": { "YourClassName": { "type": "durable-object", "state": "deleted" } } } 3. Check for external dependencies: Cloudflare will prevent the deletion if other Workers in your account still bind to the namespace [1]. You must remove those bindings first [1]. 4. Redeploy: Apply the change by deploying your Worker [1]. Once the deployment is successful and the namespace is deleted, the tombstone becomes stale [1][5]. 5. Cleanup: After the namespace is deleted, the tombstone entry will appear in the removable_entries list during your deployment output [1]. You can then safely remove the class entry entirely from your exports map in your next configuration update [1][5]. Regarding data cleanup, while deleting the class wipes all data, you may want to explicitly call storage.deleteAll within your Durable Object code before retiring a class if you need to ensure complete deallocation or handle specific application-level data cleanup before the object ceases to exist [6][4]. Keep in mind that for SQLite-backed objects, deleteAll is atomic, whereas for legacy key-value backends, it may require retries if it hits time limits [7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- migration inventory ---'
git ls-files packages/worker/migrations | sort
for f in $(git ls-files packages/worker/migrations | sort); do
  printf '%s\n' "--- $f ---"
  rg -n -C 3 'mcp_agent_sessions|DROP TABLE|deleted_classes|MCP' "$f" || true
done

printf '%s\n' '--- deletion ordering and row cleanup ---'
sed -n '360,470p' packages/worker/src/app/account-deletion.ts
sed -n '788,910p' packages/worker/src/app/account-deletion.ts
sed -n '1035,1145p' packages/worker/src/app/account-deletion.ts

printf '%s\n' '--- all direct D1 table cleanup declarations ---'
rg -n -C 3 'DROP TABLE|DELETE FROM .*mcp_agent_sessions|mcp_agent_sessions' \
  packages/worker/migrations packages/worker/src \
  | sed -n '1,260p'

Repository: kentcdodds/kody

Length of output: 35886


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

migration_dir = Path("packages/worker/migrations")
migrations = sorted(migration_dir.glob("*.sql"))
registry_creators = []
registry_drops = []
for path in migrations:
    text = path.read_text()
    if re.search(r"\b(?:CREATE\s+TABLE|INSERT\s+INTO|DELETE\s+FROM|SELECT\b.*\bFROM)\s+mcp_agent_sessions\b", text, re.I | re.S):
        registry_creators.append(path.name)
    if re.search(r"\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?mcp_agent_sessions\b", text, re.I):
        registry_drops.append(path.name)

consumers = []
for root in (Path("packages/worker/src"), Path("packages/worker/worker-configuration.d.ts")):
    paths = [root] if root.is_file() else root.rglob("*")
    for path in paths:
        if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsonc"}:
            text = path.read_text(errors="replace")
            if "mcp_agent_sessions" in text:
                consumers.append(str(path))

print({"migrations": [p.name for p in migrations]})
print({"registry_references_in_migrations": registry_creators})
print({"registry_drop_migrations": registry_drops})
print({"runtime_or_config_consumers": sorted(consumers)})
PY

Repository: kentcdodds/kody

Length of output: 975


Drop mcp_agent_sessions as a D1 migration when retiring the lane.

mcp_agent_sessions is created by 0001-squashed-init.sql, and no later migration removes it. Deleting the MCP Durable Object class removes its object storage, but it does not remove D1 rows. Add a D1 migration to drop the table, remove its consumers, and keep the owner check until the existing purge path is removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md` around
lines 68 - 70, Update the retirement plan to add a D1 migration that drops the
mcp_agent_sessions table when the McpAgent lane is removed, and remove all
consumers of that table. Keep the owner check in place until the existing
session purge path is removed, then retire it with the purge path and MCP_OBJECT
changes.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 034bf62 — the retirement list now includes a D1 migration dropping mcp_agent_sessions (removing the DO class doesn't delete the D1 rows).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

Comment thread docs/contributing/security.md Outdated
Comment on lines +316 to +317
another user's row fails to decrypt. Legacy unversioned ciphertexts still
decrypt and upgrade to `v2` whenever the value is re-encrypted on write.

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'legacy|v2\.|additionalData|decryptSecret|encryptSecret|re-?encrypt' \
  packages/worker/src/mcp/secrets

Repository: kentcdodds/kody

Length of output: 27831


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- crypto implementation ---'
cat -n packages/worker/src/mcp/secrets/crypto.ts | sed -n '70,115p'

printf '%s\n' '--- secret read and write paths ---'
cat -n packages/worker/src/mcp/secrets/service.ts | sed -n '600,750p'

printf '%s\n' '--- documentation ---'
cat -n docs/contributing/security.md | sed -n '300,325p'

printf '%s\n' '--- relevant ownership and lookup helpers ---'
rg -n -C 5 \
  'getSecretEntry|bucketId|userId|owner|secret.*scope|scope.*secret' \
  packages/worker/src/mcp/secrets packages/worker/src/mcp \
  -g '*.ts' | head -n 300

printf '%s\n' '--- standalone no-AAD AES-GCM probe ---'
node - <<'JS'
const { webcrypto } = require('crypto');
const crypto = webcrypto;

(async () => {
  const keyBytes = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode('mcp-secret-store:primary-secret-store-key-at-least-32-chars!!')
  );
  const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt', 'decrypt']);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = new TextEncoder().encode('legacy-value');
  const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);

  for (const aad of [undefined, new TextEncoder().encode('secret-store:user-a'), new TextEncoder().encode('secret-store:user-b')]) {
    try {
      const options = { name: 'AES-GCM', iv };
      if (aad !== undefined) options.additionalData = aad;
      const result = await crypto.subtle.decrypt(options, key, ciphertext);
      console.log(aad === undefined ? 'no AAD: decrypts' : `AAD ${new TextDecoder().decode(aad)}: decrypts`, new TextDecoder().decode(result));
    } catch {
      console.log(aad === undefined ? 'no AAD: rejects' : `AAD ${new TextDecoder().decode(aad)}: rejects`);
    }
  }
})();
JS

Repository: kentcdodds/kody

Length of output: 31343


Document the legacy ciphertext isolation gap.

If a legacy ciphertext is copied into another user's row, resolveSecret decrypts it because the legacy AES-GCM path uses no AAD. Metadata-only writes preserve the legacy ciphertext. State that only v2 provides owner binding, or reject legacy payloads unless they are owner-bound.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/security.md` around lines 316 - 317, Update the security
documentation around resolveSecret to explicitly document that legacy
unversioned AES-GCM ciphertext lacks owner binding and may decrypt when copied
to another user's row, including that metadata-only writes preserve it. State
that only v2 provides owner binding, or direct implementation to reject legacy
payloads unless they are owner-bound.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 034bf62 — security.md now documents that only v2 provides owner binding, that legacy ciphertexts remain swap-decryptable until rewritten (metadata-only writes preserve them), and that the compatibility branch is deliberately upgrade-on-write and gets removed once no legacy ciphertexts remain.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

Comment thread packages/worker/src/password-upgrade.ts Outdated
Comment on lines +19 to +24
if (!passwordHashNeedsUpgrade(storedHash)) return
const passwordHash = await createPasswordHash(verifiedPassword)
await db.update(usersTable, userId, {
password_hash: passwordHash,
updated_at: utcSqliteTimestamp(),
})

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Prevent a stale hash upgrade from overwriting a password change.

The helper verifies storedHash, then updates by userId only. If a password reset changes the hash between those operations, this update restores the old verified password. Use an atomic compare-and-swap that matches both the user ID and the original password_hash. Ignore a zero-row update.

  • packages/worker/src/password-upgrade.ts#L19-L24: Update only when password_hash still equals storedHash.
  • packages/worker/src/app/handlers/auth.ts#L506-L515: Keep this call only after the helper has conditional-write protection.
  • packages/worker/src/oauth-handlers.ts#L856-L865: Keep this call only after the helper has conditional-write protection.
📍 Affects 3 files
  • packages/worker/src/password-upgrade.ts#L19-L24 (this comment)
  • packages/worker/src/app/handlers/auth.ts#L506-L515
  • packages/worker/src/oauth-handlers.ts#L856-L865
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/password-upgrade.ts` around lines 19 - 24, Update the
password upgrade helper in packages/worker/src/password-upgrade.ts:19-24 to
perform an atomic conditional update matching both userId and the original
storedHash, and ignore zero-row results. In
packages/worker/src/app/handlers/auth.ts:506-515 and
packages/worker/src/oauth-handlers.ts:856-865, retain these call sites only
after the helper provides that conditional-write protection; no direct changes
are required there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 034bf62 — the rehash write is now a compare-and-swap via db.updateMany with where: { id: userId, password_hash: storedHash }, so a concurrent password change can never be overwritten by a stale upgrade; a zero-row update is silently ignored.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

devin-ai-integration Bot and others added 2 commits August 11, 2026 07:11
Co-Authored-By: Kent C. Dodds <me@kentcdodds.com>
Preview testing surfaced that every password-auth request 500ed: the
production Workers runtime throws NotSupportedError for PBKDF2 above
100,000 iterations (local workerd does not enforce the cap, so unit
tests and local e2e could not catch it). Generation and the accepted
verification ceiling both sit at the runtime cap now, over-cap hashes
are rejected cleanly before deriveBits, and a workers-pool test covers
the hash paths in the Workers runtime.

Co-Authored-By: Kent C. Dodds <me@kentcdodds.com>
@devin-ai-integration devin-ai-integration Bot changed the title Security hardening: PBKDF2 600k + rehash-on-login, secret-store AAD/versioning, prod rate-limiter fail-closed, audit fixes, SECURITY.md Security hardening: password-hash upgrade path at the Workers PBKDF2 cap, secret-store AAD/versioning, prod rate-limiter fail-closed, audit fixes, SECURITY.md Aug 11, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Preview verification

Manual testing against the preview deploy kody-pr-1382.kody-a99.workers.dev was done twice:

Round 1 (600k iterations, head 034bf62) — caught a release blocker. Every POST /auth returned 500 in ~0.18s: Cloudflare's production Workers runtime rejects PBKDF2 above 100,000 iterations (deriveBits throws NotSupportedError), while local workerd doesn't enforce the cap, so unit tests and local e2e all passed. Differential proof: same payload on baseline preview kody-pr-1380 → clean 401 {"error":"Invalid email or password."}. Fixed in f248a42 (generation + accepted ceiling at the 100k cap, over-cap hashes rejected before deriving, new password-hash.workers.test.ts).

Round 2 (head f248a42) — all scenarios pass:

Scenario Result
Wrong password → clean credential error (401, not 500) ✅
Seeded login (100k verify + rehash no-op path), ~2s ✅
Secrets v2 encrypt → decrypt round-trip via /account/secrets ✅
Same secret decrypts in a fresh session after logout/re-login ✅
Account pages smoke (Overview, Values, Activity, Integrations) — no 500s ✅
Signup ⏭️ skipped — invite-gated on preview

Secret created as v2-roundtrip-check-f248a422 reads back exactly, including in a brand-new session:

Secret decrypted to exact value in fresh session

Auth checks
  • Wrong password now shows a proper error (server 401 confirmed via curl, previously 500): Clean credential error
  • Correct seeded login lands on /account: Logged in

Test recording preview:

preview verification recording

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.

1 participant