Skip to content

fix(api): W11 hardening — scrub internal_url for anon, wire X-Idempotent-Replay (P1 regressions)#93

Merged
mastermanas805 merged 2 commits into
masterfrom
fix/w11-api-hardening-anon-idempotency
May 14, 2026
Merged

fix(api): W11 hardening — scrub internal_url for anon, wire X-Idempotent-Replay (P1 regressions)#93
mastermanas805 merged 2 commits into
masterfrom
fix/w11-api-hardening-anon-idempotency

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

Two P1-flagged API hardening regressions, one PR (tests ship in same PR per Manas's iron rule).

Fix 1 — Scrub internal_url for anonymous-tier provision responses

P1 (anon AI agent persona) found that anonymous POST /db/new, /cache/new, /nosql/new, /queue/new, /vector/new responses included internal_url: instant-pg-proxy.instant.svc.cluster.local:5432 (and the redis/mongo/nats equivalents). This leaks cluster infra topology to any unauthenticated curl, and serves zero purpose for anon callers — they can't run /deploy/new workloads (which is what the internal proxy is for) without a claimed team.

  • New helper setInternalURL(resp, tier, connectionURL, kind) in internal/handlers/internal_url.go is the single chokepoint for the omit-on-anon rule.
  • ~12 handler callsites switch from inline "internal_url": map literals to setInternalURL calls (or get the field stripped entirely on known-anonymous response paths).
  • Paid tiers (hobby, hobby_plus, pro, growth, team) still receive internal_url unchanged — Pro users running compute alongside their DB legitimately need it (DOKS doesn't hairpin public LB traffic).

Fix 2 — Lock X-Idempotent-Replay precedence vs fingerprint dedup

P1 reported Idempotency-Key appeared silently shadowed by fingerprint dedup. Investigation showed the middleware wiring in internal/router/router.go already places Idempotency BEFORE the handler (OptionalAuth → RequireWritable → Idempotency → handler), so a cache hit DOES short-circuit before the handler's fingerprint-dedup branch runs, and idempotency.go:164 DOES set the replay header. The contract was already structurally correct — what was missing was explicit black-box coverage that pins the precedence at the HTTP boundary so a future per-route misconfig fails loudly in CI.

  • Expanded idempotency.go header doc-block with an explicit precedence section.
  • Added three e2e tests covering all three branches: cache-hit replay (header set), conflict (409), no-key path (header MUST be absent even when fingerprint dedup applies).

Files changed

Fix 1

  • internal/handlers/internal_url.go — new setInternalURL helper + internalURLResponseKey / tierAnonymous named constants
  • internal/handlers/internal_url_test.goTestSetInternalURL (7 cases: anonymous omits, hobby/pro/team/growth emit, empty URL omits) + TestSetInternalURL_ReturnsSameMap
  • internal/handlers/db.go, cache.go, nosql.go, queue.go, vector.go — scrub internal_url on anonymous response paths; route paid paths through helper
  • e2e/w11_anon_internal_url_e2e_test.go — black-box assertion that POST /cache/new from anon caller returns no internal_url

Fix 2

  • internal/middleware/idempotency.go — doc-block: explicit precedence section covering all three branches
  • e2e/w11_idempotency_e2e_test.go — three tests: same-key replay sets header, different-body returns 409, no-key path still works without header

Test plan

  • go build ./... — clean
  • go vet ./... — clean
  • go test ./internal/handlers/ -run "TestSetInternalURL|TestProxiedInternalURL" -count=1 — PASS (10 cases)
  • go test ./internal/middleware/ -count=1 — PASS (no regressions)
  • go build -tags e2e ./e2e/... — clean
  • E2E run against live cluster: deferred to operator (Manas) per "DO NOT deploy" instruction. Image will batch-deploy with FIX-1/FIX-2/FIX-3/W11-F2 as v6.2.3.

Pre-existing failures (not regressed by this PR, confirmed against origin/master)

  • TestTeamSelf_Get_ReturnsTeamRow / TestTeamSelf_Patch_RenamesTeammodels.GetTeamByID Scan column-count mismatch (schema drift).
  • TestAll_ReturnsAllPlans / TestHobbyPlus_TierMatrix / TestYearlyVariants_MirrorMonthlyplans.yaml drift from hobby_plus addition in W11: add hobby_plus tier ($19/mo) to api #92.

🤖 Generated with Claude Code

mastermanas805 and others added 2 commits May 14, 2026 12:53
Anonymous /db/new, /cache/new, /nosql/new, /queue/new, /vector/new
responses leaked the cluster-internal proxy FQDN
(instant-pg-proxy.instant.svc.cluster.local, redis-proxy, mongo-proxy,
nats-proxy) to any unauthenticated curl. P1 (anon AI agent persona)
flagged this as infra topology disclosure with zero utility for the
caller — anon resources can't run /deploy/new workloads against the
proxy because POST /deploy/new requires a claimed team.

This change introduces setInternalURL(resp, tier, connectionURL, kind)
in internal_url.go as the single chokepoint for the omit-on-anon rule.
~12 handler callsites switch from inline `"internal_url":` map literals
to setInternalURL calls (or get the field stripped entirely on
known-anonymous response paths). Twin response paths (db.go, cache.go,
nosql.go) keep an inline defensive guard against tier=="anonymous"
even though twin requires an authenticated team in practice.

Paid tiers (hobby, hobby_plus, pro, growth, team) still receive
internal_url unchanged — Pro users running compute alongside their DB
legitimately need it (DOKS doesn't hairpin public LB traffic).

Tests:
- internal/handlers/internal_url_test.go::TestSetInternalURL —
  anonymous omits, hobby/pro/team/growth emit, empty URL omits on
  all tiers. Plus TestSetInternalURL_ReturnsSameMap for the chaining
  contract.
- e2e/w11_anon_internal_url_e2e_test.go — POST /cache/new from
  anonymous IP returns body with NO internal_url field, while
  connection_url stays populated.

Pre-existing failures NOT caused by this change (confirmed against
origin/master): TestTeamSelf_Get_ReturnsTeamRow,
TestTeamSelf_Patch_RenamesTeam (Scan column-count mismatch),
TestAll_ReturnsAllPlans / TestHobbyPlus_TierMatrix /
TestYearlyVariants_MirrorMonthly (plans.yaml drift from hobby_plus
addition in #92).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P1 reported `Idempotency-Key` appeared to be silently shadowed by
fingerprint dedup: same-key + same-body returned the fingerprint's
existing token AND no `X-Idempotent-Replay: true` header surfaced.

Investigation showed the middleware wiring in internal/router/router.go
already places Idempotency BEFORE the handler in the per-route chain
(OptionalAuth → RequireWritable → Idempotency → handler), so a cache
hit DOES short-circuit before the handler's fingerprint-dedup branch
runs, and idempotency.go:164 DOES set the replay header on the cached
path. The contract was already structurally correct — what was missing
was explicit black-box coverage that pins the precedence at the HTTP
boundary so a future per-route misconfig (e.g. middleware accidentally
moved AFTER the handler) fails loudly in CI rather than re-introducing
the silent regression.

This change:

1. Expands the idempotency.go header doc-block with an explicit
   precedence section covering the three branches:
   - key + cached     ⇒ replay cached token, X-Idempotent-Replay: true
   - key + no cache   ⇒ handler runs; response cached for next call
   - no key           ⇒ handler's fingerprint dedup; header NEVER set

2. Adds e2e/w11_idempotency_e2e_test.go with three black-box tests:
   - TestE2E_W11_Idempotency_ReplaysWithHeader: two calls same key +
     body return same token, second response carries the header.
   - TestE2E_W11_Idempotency_DifferentBody_Returns409: same key +
     different body → 409 idempotency_key_conflict.
   - TestE2E_W11_FingerprintDedup_NoIdempotencyKey_StillWorks:
     fingerprint dedup still functions when no key is sent, and the
     replay header is correctly absent on that path.

No middleware code-path changes — the existing unit tests in
internal/middleware/idempotency_test.go (TestIdempotency_ReplaySameBody_
CachedResponse, TestIdempotency_ReplayDifferentBody_Returns409, etc.)
continue to pin the same contracts at the unit layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 merged commit 15e07c9 into master May 14, 2026
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