Files
sencho/backend/src/helpers/proxyExemptPaths.ts
T
Anso 9fb4ccccff fix(fleet-secrets): restrict bundle management to admin hub sessions (#1274)
* fix(fleet-secrets): restrict bundle management to admin hub sessions

Fleet Secrets exposes decrypted environment-variable values and writes
credentials across the fleet, so every route now requires an admin role,
runs only on the instance you are signed into, and rejects long-lived API
tokens:

- Add an admin-role check to all secrets routes; the frontend Secrets tab
  renders only for admin users so the affordance matches the backend gate.
- Add /api/secrets/ to the hub-only path list so a request carrying a
  remote node id cannot be proxied to read another node's decrypted values.
- Reject API tokens on every secrets route (browser admin sessions only),
  matching how registry credentials are handled.

Also adds lifecycle and developer-mode diagnostic logging (never the secret
values) and tests covering the admin boundary on every endpoint, API-token
rejection, hub-only enforcement, and diagnostic gating.

* fix(fleet-secrets): require a signed-in user session for all secrets routes

The earlier API-token rejection only blocked opaque API tokens. node_proxy
and pilot_tunnel JWTs are mapped to an admin role by the auth middleware
without an API-token scope, so they still passed the admin gate and could
read decrypted bundles via GET /api/secrets/:id.

Replace the API-token check with requireUserSession, which rejects API tokens
and node_proxy / pilot_tunnel machine credentials (userId 0) on every secrets
route, returning SESSION_REQUIRED. The admin role is still enforced after.

Tests now assert SESSION_REQUIRED for a full-admin API token across all nine
routes and for node_proxy and pilot_tunnel JWTs.

* test(fleet-secrets): mint the rejection-test token via the real endpoint

The machine-credential test reconstructed an API token by sha256-hashing a
raw key inline. That duplicated a hashing sink that CodeQL's
js/insufficient-password-hash query flags (a false positive for a 256-bit
random token, but a new occurrence in the diff). Create the token through
POST /api/api-tokens instead, so the hashing stays in the production path
and the test carries none of its own. Behavior and coverage are unchanged.
2026-06-01 19:47:06 -04:00

69 lines
2.9 KiB
TypeScript

// Path prefixes whose /api/* requests are handled locally even when an
// x-node-id header targets a remote node. These endpoints are gateway-level
// concerns (auth, node registry, licensing, fleet aggregation, webhooks,
// meta) that must never be proxied to a remote Sencho instance.
//
// Consumed by:
// - middleware/jsonParser.ts → skip JSON parsing only for non-exempt remote proxy requests
// - middleware/nodeContext.ts → skip node resolution for exempt paths
export const PROXY_EXEMPT_PREFIXES: readonly string[] = [
'/api/auth/',
'/api/nodes',
'/api/license',
'/api/fleet/',
'/api/webhooks',
'/api/meta',
];
/** Returns true when the path should bypass the remote proxy (handled locally). */
export function isProxyExemptPath(path: string): boolean {
for (const prefix of PROXY_EXEMPT_PREFIXES) {
if (path.startsWith(prefix)) return true;
}
return false;
}
// Path prefixes that are hub-only: they must be served on the instance you are
// signed into and never proxied to a remote node. This covers state owned by
// the local hub (centralized audit, fleet schedules, notification routing
// rules, the admin-only aggregated logs feed and its stream counters) and
// private registry credentials, which are stored and managed per instance.
// Routed to the local hub when nodeId resolves to local, but rejected when
// nodeId resolves to a remote node so a script/curl call cannot trick the proxy
// into forwarding the request across a node boundary. This matters for the logs
// feed (its admin gate lives in the local route handler, which the proxy would
// skip when forwarding a remote nodeId) and for registries and Fleet Secrets (a
// proxied request would otherwise carry a plaintext secret to, or read stored
// secret values from, the remote; the secrets routes' admin gate also lives in
// the local route handler, which the proxy would skip).
//
// Entries are stored with a trailing slash; the matcher accepts the exact
// collection path (without the trailing slash) AND any sub-path under it,
// so e.g. `/api/scheduled-tasks` and `/api/scheduled-tasks/42` both match.
// A bare startsWith() would let the collection path silently fall through
// and be forwarded by the remote proxy.
//
// Frontend nav surfaces are gated separately via `HUB_ONLY_VIEWS` in
// useViewNavigationState.ts; this list is the backend defense-in-depth.
//
// Consumed by:
// - middleware/hubOnlyGuard.ts → 403 when nodeId is remote
export const HUB_ONLY_PREFIXES: readonly string[] = [
'/api/scheduled-tasks/',
'/api/audit-log/',
'/api/notification-routes/',
'/api/logs/global/',
'/api/system/log-stream-metrics/',
'/api/registries/',
'/api/secrets/',
];
/** Returns true when the path is hub-only and must not be proxied to a remote node. */
export function isHubOnlyPath(path: string): boolean {
for (const prefix of HUB_ONLY_PREFIXES) {
if (path.startsWith(prefix)) return true;
if (path === prefix.slice(0, -1)) return true;
}
return false;
}