Security MEDIUM sweep (plan-2026-05-18-security-medium-sweep). Closes the 5 MEDIUM findings from the post-1.12.0 massu-security-reviewer audit-loop with structural drift-guards for each. M-1 IP_HASH_PEPPER consistency (silent-fallback vs throw class), M-2 webhook POST schema symmetry, M-3 Lemon Squeezy webhook idempotency parity with Stripe, M-4 v1 audit UUID echo redaction, M-5 SSO IdP host allowlist (precondition for SSO re-enable). New CR-51 + VR-PEPPER-GUARD + pattern-scanner Check 27.
Added
- M-1 / CR-51 / VR-PEPPER-GUARD —
website/src/lib/ip/pepper-guard.tsexportingrequireIpHashPepper()+hashIpWithPepper(ip, { length: 16 | 32 })as the SOLE allowed reader of the IP hash pepper env var. Production fail-closed viaMissingPepperError; documented test override viaMASSU_TEST_ALLOW_EMPTY_PEPPER=1. Closes the structural drift class whereevidence/[id]/download/route.ts:21silent-fallback diverged fromlicense/activate/route.ts:34-40throw. - M-2 / VR-SCHEMA-SYMMETRY —
webhookCreateSchemainsrc/lib/validations.ts, symmetric towebhookUpdateSchema.descriptioncapped at 500 chars on BOTH schemas (CR-9 bypass-prevention).route-schema-symmetry.test.tsdrift-guard scans everyapp/api/v1/*/route.tsPOST/PATCH/PUT handler forSchema.safeParseinvocation. - M-3 / VR-LS-IDEMPOTENCY — migration
042_lemon_squeezy_webhook_events.sql+lemon_squeezy_event_apply(p_event_id, p_event_type, p_payload, ...)RPC returning TEXTprocessing_status(processed_ok|duplicate_ignored|permanent_failure|transient_failure). Route handler maps each status to HTTP 200/200/422/500 — 422 stops LS retries on permanent failures (Stripe's BOOLEAN return can't carry this contract). Down migration shipped alongside (.down.sql) per §6.5 Rollback Plan. - M-4 / VR-V1-NO-UUID-LEAK —
v1-error-response-no-uuid-leak.test.tsdrift-guard scanning everyapp/api/v1/**/route.tssource for caller-controlled UUID interpolation in error/note response surfaces (apiError,note =, etc.).${auth.orgId}self-echo explicitly permitted (caller's auth context already binds them). - M-5 / VR-SSO-IDP-ALLOWLIST —
website/src/lib/sso/idp-allowlist.tswithSSO_IDP_HOSTS_BY_PROVIDERconstant +validateSsoUrlAgainstProvider(provider, ssoUrl)+inferSsoProviderFromUrl(ssoUrl). TLD-aware wildcard host match (*.okta.comdoes NOT matchevil-okta.com); HTTPS-only scheme enforcement. Covers Okta, Auth0, Azure AD (microsoftonline.com + .us), Google Workspace. Generic OIDC deferred toplan-B.3-followupSSO re-enable. MASSU_SSO_ALLOWLIST_LOG_ONLYenv-flag for the M-5 staged rollout —isSsoAllowlistLogOnly()insso-flag.ts. Operator runs 24h log-only post-deploy to monitor false-positives before flipping to enforcing.scripts/massu-pattern-scanner.shCheck 27 — bash drift-guard for security-sensitive env-var patterns. 27a flags rawprocess.env.<NAME>PEPPERoutsidelib/<purpose>/<purpose>-guard.ts; 27b flags anyprocess.env.<NAME>(PEPPER|SECRET|KEY) ?? <literal>silent-fallback adjacent to such reads (framework-platform keys exempt).- CR-51 canonical rule in
.claude/CLAUDE.md: security-sensitive env vars must use a dedicated guard helper. Three-layer enforcement: pattern-scanner Check 27 + vitest drift-guard + the guard helper is the ONLY allowed reader. - 5 new vitest drift-guards:
ip-pepper-guard-usage.test.ts,route-schema-symmetry.test.ts,lemon-squeezy-event-atomic.test.ts,v1-error-response-no-uuid-leak.test.ts,sso-idp-allowlist.test.ts(+23 happy/negative/integration cases for M-5 alone).
Changed
- Lemon Squeezy webhook route (
src/app/api/lemon-squeezy/webhook/route.ts) — 4 handlers (handleOrderCreated,handleLicenseKeyCreated,handleOrderRefunded,handleSubscriptionCancelled) refactored to dispatch throughlemon_squeezy_event_applyRPC. Directfrom('book_purchases').insert/from('organizations').updatecalls REMOVED from the route. Pre-RPC test-mode mismatch rejection now returnspermanent_failure(HTTP 422) instead of throwing. src/app/api/v1/audit/route.ts:66—${actor}UUID echo dropped from non-empty-org-no-rows note. New message: "No audit entries for the actor filter in this org. Verify the user is a member of org ${auth.orgId}." Eliminates the UUID-existence-probe side-channel for authenticated API-key holders.src/app/api/sso/route.ts— invokesinferSsoProviderFromUrl(ssoConfig.sso_url)before constructingredirect_url. Non-allowlisted host returns 502 withX-SSO-Validation-Failure: idp-host-not-allowlisted(or logs-only underMASSU_SSO_ALLOWLIST_LOG_ONLY=1).src/app/api/sso/callback/route.ts:exchangeOidcCode— same allowlist guard applied BEFORE any outboundfetch()to the IdP token endpoint. Closes the SSRF-to-IdP vector at the callback layer (P5-007).scripts/massu-security-scanner.shCheck 6 — RLS coverage scanner now excludes*.down.sqlrollback scripts (CR-46 structural fix; the literal "CREATE TABLE" only appears in their explanatory comments).website/src/data/stats.ts— Database Tables 44 → 45 (newlemon_squeezy_webhook_events), Canonical Rules 16 → 17 (new CR-51). Marketing count drift-guard covers both.website/src/tests/changelog-parse.test.ts:EXPECTED_COUNTbumped 40 → 41.website/src/tests/migration-grant-discipline.test.ts:SERVICE_ROLE_ONLY_TABLESaddslemon_squeezy_webhook_events: '042_lemon_squeezy_webhook_events.sql'.
Fixed
- Pre-existing
plan-token-changelog-coverage.test.tsPTCC-03 failure surfaced during the loop — 3 post-tag chore-commit plan-tokens (plan-stage-d-medium-sweep,plan-stage-e-low-info-sweep,plan-2026-05-16-prelaunch-audit) added to the documented-divergence allowlist with the same rationale pattern as the existingplan-stage-c-high-batchentry.