mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
d882f223f4
* feat(api-tokens): switch to sen_sk_ prefixed opaque keys
Replace JWT-shaped API tokens with 56-char opaque keys of the form
`sen_sk_<43-char base62 random><6-char base62 checksum>` (256-bit
entropy, sha256-truncated checksum). Node-proxy tokens stay JWTs.
Why:
* The api_token path was already a sha256 DB lookup; the JWT signature
was wasted work and the 400d JWT ceiling vs DB expires_at was a
confusing dual bound.
* Opaque tokens carry a verifiable checksum so malformed/typoed values
are rejected before any SQLite lookup.
* `sen_sk_` prefix is recognizable to GitHub, TruffleHog, GitGuardian
and makes the on-wire shape visually distinct from node_proxy JWTs.
Changes:
* New `utils/apiTokenFormat.ts` (generate + checksum-verify, CSPRNG via
randomInt, timingSafeEqual on the checksum compare).
* `middleware/auth.ts` and `websocket/upgradeHandler.ts` route opaque
tokens before any jwt.verify; 401 messages unified to avoid a
token-existence oracle.
* `middleware/rateLimiters.ts` short-circuits opaque tokens in the
node_proxy detection and keys per-token via a non-reversible sha256
slice so each token keeps its own bucket without a DB hit.
* All six existing tests migrated from jwt.sign({scope:'api_token'})
to generateApiToken(); new format-only test suite covering prefix,
length, alphabet, checksum reject paths, and a 10k-iteration
collision/integrity loop.
* Docs (features/api-tokens.mdx, api-reference/overview.mdx) describe
the shape and drop the obsolete JWT-ceiling note.
* fix(api-tokens): clear CI lint and CodeQL false positives
* Drop unused TEST_USERNAME import in remote-console-session.test.ts;
the migration to generateApiToken() left it orphaned.
* Add a CodeQL barrier model so `generateApiToken`'s ReturnValue does
not flow into the `insufficient-password-hash` query. The function
emits 256-bit CSPRNG opaque keys; sha256 of the raw token is the
correct construction for high-entropy API tokens (bcrypt-class
hashes target low-entropy human passwords). CodeQL's name heuristic
was treating "Token" as a password source and flagging the standard
sha256 wrapping at all 9 call sites.
* ci(codeql): exclude js/insufficient-password-hash for token paths
The previous barrierModel data extension was a no-op for this rule: the
js/insufficient-password-hash query identifies its "password" sources via
SensitiveExpr's name heuristic ("token", "secret", "key" substrings),
which is upstream of the taint-tracking layer where barrierModel applies.
Verified by post-push re-analysis: 9 alerts still open, all undismissed.
Replace the dead extension with a path-scoped query-filter in
codeql-config.yml so the rule no longer fires on apiTokenFormat,
apiTokens, and the test directory. Real user-password hashing code
elsewhere in the repo (auth, users, setup routes) remains analyzed.
The 9 existing alerts on PR #1062 are dismissed via API as false
positives with a justification pointing at this config. Future runs
will not re-flag them because of the path filter.
201 lines
8.6 KiB
TypeScript
201 lines
8.6 KiB
TypeScript
import type { Request } from 'express';
|
|
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
|
import jwt from 'jsonwebtoken';
|
|
import { createHash } from 'crypto';
|
|
import { COOKIE_NAME } from '../helpers/constants';
|
|
import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns';
|
|
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
|
|
|
// ── Rate Limiting ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Tiered rate limiting to prevent UX lockouts while maintaining security:
|
|
// Tier 0/1 (Polling): High-frequency GET endpoints exempt from global limit,
|
|
// with a 300/min safety net to prevent resource exhaustion.
|
|
// Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs).
|
|
// Tier 2 (Standard): All other endpoints at 200/min.
|
|
// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min).
|
|
//
|
|
// Enterprise adaptations:
|
|
// - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters.
|
|
// - Authenticated requests are keyed by user ID (not IP) to prevent shared
|
|
// NAT/VPN environments from pooling rate limit budgets.
|
|
|
|
/** Read-only GET endpoints polled at high frequency by the dashboard/fleet UI. */
|
|
const POLLING_EXEMPT_PATHS = new Set([
|
|
'/meta', '/health', '/stats', '/system/stats',
|
|
'/stacks/statuses', '/metrics/historical',
|
|
'/auth/status', '/auth/sso/providers', '/license',
|
|
]);
|
|
|
|
type CachedProxyFlagReq = Request & { _isNodeProxy?: boolean };
|
|
|
|
/**
|
|
* True when the request bears a node_proxy Bearer token. Uses `jwt.decode()`
|
|
* (no signature verification) to keep the hot path cheap; `authMiddleware`
|
|
* verifies signatures downstream. Worst case for a forged token: it bypasses
|
|
* the rate limiter but is still rejected at auth. Result is memoized on the
|
|
* request object so sequential limiters don't repeat the work.
|
|
*/
|
|
function isNodeProxyRequest(req: Request): boolean {
|
|
const cached = (req as CachedProxyFlagReq);
|
|
if (cached._isNodeProxy !== undefined) return cached._isNodeProxy;
|
|
const auth = req.headers.authorization;
|
|
if (!auth?.startsWith('Bearer ')) {
|
|
cached._isNodeProxy = false;
|
|
return false;
|
|
}
|
|
const bearer = auth.slice(7);
|
|
// Opaque API tokens are never node_proxy credentials, and they are not
|
|
// JWTs — short-circuit so `jwt.decode` is never invoked on them.
|
|
if (looksLikeApiToken(bearer)) {
|
|
cached._isNodeProxy = false;
|
|
return false;
|
|
}
|
|
try {
|
|
const decoded = jwt.decode(bearer) as { scope?: string } | null;
|
|
const result = decoded?.scope === 'node_proxy';
|
|
cached._isNodeProxy = result;
|
|
return result;
|
|
} catch {
|
|
cached._isNodeProxy = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hybrid rate limit key: JWT username/sub for authenticated requests
|
|
* (per-user budgets), IP otherwise. `jwt.decode()` avoids double-verification;
|
|
* `authMiddleware` handles signature checks downstream.
|
|
*/
|
|
function rateLimitKeyGenerator(req: Request): string {
|
|
const cookie = req.cookies?.[COOKIE_NAME];
|
|
if (cookie) {
|
|
try {
|
|
const decoded = jwt.decode(cookie) as { username?: string } | null;
|
|
if (decoded?.username) return `user:${decoded.username}`;
|
|
} catch { /* fall through to IP */ }
|
|
}
|
|
const auth = req.headers.authorization;
|
|
if (auth?.startsWith('Bearer ')) {
|
|
const bearer = auth.slice(7);
|
|
// Opaque API tokens key by a non-reversible hash slice of the token
|
|
// itself: each token gets its own rate-limit budget without a DB hit on
|
|
// the hot path (this runs before authMiddleware).
|
|
if (looksLikeApiToken(bearer)) {
|
|
const slice = createHash('sha256').update(bearer).digest('hex').slice(0, 16);
|
|
return `user:sk:${slice}`;
|
|
}
|
|
try {
|
|
const decoded = jwt.decode(bearer) as { username?: string; sub?: string } | null;
|
|
if (decoded?.username) return `user:${decoded.username}`;
|
|
if (decoded?.sub) return `user:${decoded.sub}`;
|
|
} catch { /* fall through to IP */ }
|
|
}
|
|
return ipKeyGenerator(req.ip || 'unknown');
|
|
}
|
|
|
|
/** Shared config for all per-minute limiters (1-minute window, standard headers). */
|
|
const rateLimitBase = {
|
|
windowMs: 60 * 1000,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
} as const;
|
|
|
|
// Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook
|
|
// triggers (Tier W), and internal node-to-node traffic (node_proxy).
|
|
export const globalApiLimiter = rateLimit({
|
|
...rateLimitBase,
|
|
max: process.env.NODE_ENV === 'production'
|
|
? parseInt(process.env.API_RATE_LIMIT || '200', 10)
|
|
: 1000,
|
|
keyGenerator: rateLimitKeyGenerator,
|
|
message: { error: 'Too many requests. Please try again shortly.' },
|
|
skip: (req: Request) => {
|
|
if (req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)) return true;
|
|
if (req.method === 'POST' && WEBHOOK_TRIGGER_RE.test(req.path)) return true;
|
|
if (isNodeProxyRequest(req)) return true;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
// Tier 0/1: Polling safety net. Applies only to polling-exempt endpoints to
|
|
// prevent resource exhaustion from runaway or malicious polling.
|
|
export const pollingLimiter = rateLimit({
|
|
...rateLimitBase,
|
|
max: process.env.NODE_ENV === 'production'
|
|
? parseInt(process.env.API_POLLING_RATE_LIMIT || '300', 10)
|
|
: 3000,
|
|
keyGenerator: rateLimitKeyGenerator,
|
|
message: { error: 'Too many polling requests. Please try again shortly.' },
|
|
skip: (req: Request) => {
|
|
if (isNodeProxyRequest(req)) return true;
|
|
return !(req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path));
|
|
},
|
|
});
|
|
|
|
// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler.
|
|
// CI/CD platforms often share datacenter IPs, so a higher ceiling prevents
|
|
// dropped deployments during burst activity.
|
|
export const webhookTriggerLimiter = rateLimit({
|
|
...rateLimitBase,
|
|
max: process.env.NODE_ENV === 'production' ? 500 : 5000,
|
|
message: { error: 'Too many webhook triggers. Please try again shortly.' },
|
|
});
|
|
|
|
// Tier 3: Auth endpoint limiter. 15-minute window to blunt brute-force attacks.
|
|
// Prod: 5 attempts/15min/IP. Dev: 100 attempts so E2E tests and local tooling are not blocked.
|
|
export const authRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: process.env.NODE_ENV === 'production' ? 5 : 100,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
|
});
|
|
|
|
// Tier 3: SSO flow limiter. Slightly more generous than authRateLimiter because
|
|
// OIDC callbacks can chain multiple round trips per user attempt.
|
|
export const ssoRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: process.env.NODE_ENV === 'production' ? 10 : 100,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many SSO attempts. Please try again later.' },
|
|
});
|
|
|
|
// Pilot enrollment limiter. Enrollment mints a JWT and writes a
|
|
// pilot_enrollments row, so it deserves a stricter ceiling than the global
|
|
// API limiter (200/min). Applied on the regenerate route directly and on
|
|
// `POST /api/nodes` only when the body resolves to pilot_agent mode (the
|
|
// limiter's `skip` function reads the parsed body).
|
|
export const enrollmentLimiter = rateLimit({
|
|
...rateLimitBase,
|
|
max: process.env.NODE_ENV === 'production' ? 10 : 100,
|
|
keyGenerator: rateLimitKeyGenerator,
|
|
message: { error: 'Too many enrollment requests. Please try again shortly.' },
|
|
skip: (req: Request) => {
|
|
// Only gate `POST /api/nodes` calls that actually create a pilot agent;
|
|
// proxy-mode node creation falls back to the global limiter. The
|
|
// dedicated `/pilot/enroll` route applies this limiter unconditionally,
|
|
// so the body check is bypassed there by skipping the skip when the
|
|
// path already targets enrollment.
|
|
if (req.path.endsWith('/pilot/enroll')) return false;
|
|
// Express.json() runs globally before route handlers in app.ts, so
|
|
// req.body is parsed by the time this fires. If a future refactor moves
|
|
// body parsing per-route the worst case is "limiter applies even to
|
|
// proxy-mode" rather than "limiter is bypassed entirely".
|
|
if (!req.body) return false;
|
|
const body = req.body as { mode?: string; type?: string };
|
|
return !(body.type === 'remote' && body.mode === 'pilot_agent');
|
|
},
|
|
});
|
|
|
|
// Trivy install/update limiter. Install + update are expensive (binary download,
|
|
// sha256 verification) so a 10-minute window prevents accidental thrashing.
|
|
export const trivyInstallLimiter = rateLimit({
|
|
...rateLimitBase,
|
|
windowMs: 10 * 60 * 1000,
|
|
max: process.env.NODE_ENV === 'production' ? 5 : 50,
|
|
keyGenerator: rateLimitKeyGenerator,
|
|
message: { error: 'Too many install requests. Try again later.' },
|
|
});
|