Files
pad/internal
xarmian c2b67f5a9d fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655) (#182)
* fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655)

middleware_auth.go:184-189 and middleware_csrf.go:44-48 permanently
exempted /api/v1/admin/plan, /admin/stripe-customer-id, and
/admin/user-by-customer from RequireAuth and CSRFProtect — by path, not
by credential. In self-host mode these endpoints still responded to
every anonymous network caller (with "Cloud mode not configured"),
confirming their existence and telegraphing that the auth surface was
non-standard.

Three tightly-coupled changes:

1. Narrow both carve-outs from path-based to credential-based. The new
   isCloudSecretAuthAttempt(r) helper checks for X-Cloud-Secret header
   or legacy ?cloud_secret query-param; only requests that present one
   bypass auth/CSRF. Cookie-based admin callers continue through the
   normal session + CSRF gate.

2. Wrap the three endpoints in a dedicated requireCloudMode group.
   Self-host mode → 404, no endpoint-existence disclosure.

3. Admin callers via cookie now properly require CSRF for these
   endpoints (they previously bypassed), bringing them in line with
   every other /admin/* endpoint.

Tests (cloud_admin_gate_test.go):
- TestCloudAdminGate_SelfHost_Returns404 — anon + X-Cloud-Secret in
  self-host → 404 (requireCloudMode fires).
- TestCloudAdminGate_NoCloudSecret_RequiresAuth — cloud mode + no
  secret → 401 from auth gate (not the old "Cloud mode not configured").
- TestCloudAdminGate_ValidCloudSecret_PassesAuthAndCSRF — sidecar
  with matching X-Cloud-Secret reaches the handler; neither 401 nor
  403.
- TestCloudAdminGate_QueryParamSecret_BackwardCompat — legacy
  ?cloud_secret= on GET still works (TASK-656 removes this next).

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): scope cloud-secret auth bypass to cloud admin paths per Codex P0

Codex caught a regression in the first cut: isCloudSecretAuthAttempt(r)
only checked for the presence of X-Cloud-Secret/?cloud_secret, so
setting either header on ANY path (e.g. GET /api/v1/workspaces) would
bypass RequireAuth globally. An anonymous attacker could list or
create workspaces just by adding one of those markers.

Add a cloudAdminPaths whitelist and require the request path to be one
of the three cloud admin endpoints before honoring the bypass. Defined
as a map so a future /api/v1/... route can't accidentally inherit it.

Regression test TestCloudAdminGate_BypassScopedToCloudPaths:
- GET /workspaces + X-Cloud-Secret → 401 (not bypass)
- GET /workspaces?cloud_secret=x → 401 (not bypass)
- POST /workspaces + X-Cloud-Secret → 4xx (CSRF 403 or auth 401)

* fix(server): make cloud-secret path gate visible at call sites

Codex re-flagged the path scoping on PR #182 — even after the fix, the
helper name 'isCloudSecretAuthAttempt' made the path scoping invisible
at the call site. Split into two primitives:
 - isCloudAdminPath(path) — path whitelist check
 - hasCloudSecretMarker(r)  — header/query marker check

Both middleware now combine them explicitly:
  if isCloudAdminPath(path) && hasCloudSecretMarker(r) { ... }

Behaviorally identical to the previous fix — tests still show
GET /workspaces with X-Cloud-Secret returning 401, POST /workspaces
with X-Cloud-Secret returning 403. Just makes the invariant readable
in RequireAuth and CSRFProtect without having to jump to the helper.

* fix(server): preserve body-cloud_secret auth for sidecar POSTs per Codex P1

Codex caught that POST sidecar calls carrying cloud_secret only in the
JSON body (the current pad-cloud sidecar behavior) would fail at
RequireAuth/CSRFProtect after this PR — handler-level validation never
runs. Breaking deployed sidecars isn't the intent of TASK-655; TASK-656
deprecates body+query cloud_secret in favor of X-Cloud-Secret header
exclusively, but that's a separate migration.

Add body peek to hasCloudSecretMarker for POST/PUT requests with
application/json content-type:
 - Read up to 64 KB of r.Body into a buffer.
 - Replace r.Body with an io.NopCloser wrapping the buffer so
   downstream handlers can still decode the JSON.
 - Return true if the parsed body has a non-empty cloud_secret field.

Parse errors and missing fields → false (request falls through to the
normal auth rejection, no permissiveness). The peek only runs when
the caller is already hitting a cloud admin path via the explicit
isCloudAdminPath() gate at the call sites, so the body-read cost is
bounded to three endpoints.

Test: TestCloudAdminGate_BodySecret_BackwardCompat posts with
cloud_secret in the JSON body and no X-Cloud-Secret header, asserts
the request reaches the handler (404 from unknown user_id, not
401/403 from middleware).
2026-04-21 22:15:52 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00