mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
9fb4ccccff
* 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.
69 lines
2.9 KiB
TypeScript
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;
|
|
}
|