From 2d6b4c233daa178de485dfeb198fc90376949ca4 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 1 Apr 2026 20:50:43 -0400 Subject: [PATCH] fix(security): pre-launch security hardening audit & remediation (#320) - Webhook HMAC: capture raw request bytes via express.json verify callback instead of re-serializing with JSON.stringify - AES-256-GCM: use NIST-recommended 12-byte IV (backward compatible with existing 16-byte IVs) - Node proxy tokens: add 1-year default expiry (previously no expiry) - Host console env filtering: pattern-based approach blocking SECRET, PASSWORD, TOKEN, KEY, CREDENTIAL keywords (previously only 4 explicit keys) - CORS: deny cross-origin requests when FRONTEND_URL is unset in production (previously fell back to allowing all origins) --- CHANGELOG.md | 20 ++++++++++---------- backend/src/index.ts | 20 ++++++++++++++------ backend/src/services/CryptoService.ts | 2 +- backend/src/services/HostTerminalService.ts | 9 +++++++-- docs/features/host-console.mdx | 1 + docs/features/multi-node.mdx | 3 ++- docs/features/webhooks.mdx | 1 + 7 files changed, 36 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e06dc1da..a72c2b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **security:** use raw request bytes for webhook HMAC signature verification instead of re-serialized JSON — prevents signature mismatches from serialization differences +* **security:** use NIST-recommended 12-byte IV for AES-256-GCM encryption (backward compatible with existing 16-byte IVs) +* **security:** add 1-year default expiry to node proxy JWT tokens — previously issued without expiry +* **security:** pattern-based env var filtering in host console — blocks variables containing SECRET, PASSWORD, TOKEN, KEY, or CREDENTIAL (previously only filtered 4 explicit keys) +* **security:** deny CORS when `FRONTEND_URL` is unset in production — previously fell back to allowing all origins + ## [0.25.0](https://github.com/AnsoCode/Sencho/compare/v0.24.2...v0.25.0) (2026-04-02) @@ -18,16 +28,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **security:** enforce stack name validation on all routes ([#314](https://github.com/AnsoCode/Sencho/issues/314)) ([1ab04be](https://github.com/AnsoCode/Sencho/commit/1ab04be235cc0d3020d17dfb3028e4679206b886)) -## [Unreleased] - -### Added - -* **api:** global rate limiter on all `/api/` routes — 100 requests/min per IP in production (configurable via `API_RATE_LIMIT` env var). Auth endpoints retain their existing stricter limits (5 req/15min) which stack independently. Returns `429 Too Many Requests` when exceeded. - -### Fixed - -* **security:** enforce `isValidStackName()` on all routes that accept a `stackName` parameter — 11 routes had no validation and 2 used a weaker manual check; all now use the canonical `^[a-zA-Z0-9_-]+$` regex guard, returning `400 Invalid stack name` on rejection - ## [0.24.1](https://github.com/AnsoCode/Sencho/compare/v0.24.0...v0.24.1) (2026-04-01) diff --git a/backend/src/index.ts b/backend/src/index.ts index d9778fa4..73b44580 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -124,8 +124,8 @@ app.use(helmet({ // CORS - in production restrict to the configured frontend origin. // In development, mirror the request origin so Vite's dev server works. -const corsOrigin = process.env.NODE_ENV === 'production' && process.env.FRONTEND_URL - ? process.env.FRONTEND_URL +const corsOrigin = process.env.NODE_ENV === 'production' + ? (process.env.FRONTEND_URL || false) : true; app.use(cors({ @@ -148,6 +148,13 @@ const globalApiLimiter = rateLimit({ app.use('/api/', globalApiLimiter); +// JSON body parser that also captures the raw bytes for HMAC verification. +const jsonParser = express.json({ + verify: (req, _res, buf) => { + (req as unknown as { rawBody: Buffer }).rawBody = buf; + }, +}); + // Conditionally parse JSON bodies. Remote proxy requests must NOT have their body // consumed here: express.json() drains the IncomingMessage stream into req.body // and http-proxy then pipes an already-ended stream to the remote server. @@ -175,7 +182,7 @@ app.use((req: Request, res: Response, next: NextFunction): void => { return; } } - express.json()(req, res, next); + jsonParser(req, res, next); }); app.use(cookieParser()); @@ -222,6 +229,7 @@ declare global { user?: { username: string; role: UserRole; userId: number }; nodeId: number; apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin'; + rawBody?: Buffer; } } } @@ -482,8 +490,8 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r res.status(500).json({ error: 'No JWT secret configured on this instance.' }); return; } - // No expiry - this token is managed by the admin who pastes it into the main dashboard - const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret); + // Default 1-year expiry — admin should rotate tokens periodically + const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' }); res.json({ token }); } catch (error: any) { res.status(500).json({ error: error.message || 'Failed to generate node token' }); @@ -1730,7 +1738,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi return; } - const rawBody = JSON.stringify(req.body ?? {}); + const rawBody = req.rawBody?.toString('utf-8') ?? JSON.stringify(req.body ?? {}); const svc = WebhookService.getInstance(); if (!svc.validateSignature(rawBody, webhook.secret, signature)) { res.status(401).json({ error: 'Invalid signature' }); diff --git a/backend/src/services/CryptoService.ts b/backend/src/services/CryptoService.ts index 7316b38e..a09db5d5 100644 --- a/backend/src/services/CryptoService.ts +++ b/backend/src/services/CryptoService.ts @@ -4,7 +4,7 @@ import path from 'path'; const ALGORITHM = 'aes-256-gcm'; const KEY_LENGTH = 32; // 256 bits -const IV_LENGTH = 16; +const IV_LENGTH = 12; // NIST SP 800-38D recommended length for GCM const ENCRYPTED_PREFIX = 'enc:'; export class CryptoService { diff --git a/backend/src/services/HostTerminalService.ts b/backend/src/services/HostTerminalService.ts index 98436682..b86bfc4c 100644 --- a/backend/src/services/HostTerminalService.ts +++ b/backend/src/services/HostTerminalService.ts @@ -18,9 +18,14 @@ export class HostTerminalService { // Strip sensitive backend secrets from the PTY environment so they are not // visible to the console user via `env` / `printenv`. - const SENSITIVE_KEYS = ['JWT_SECRET', 'AUTH_PASSWORD', 'AUTH_PASSWORD_HASH', 'DATABASE_URL']; + // Pattern-based filtering: block any env var containing sensitive keywords. + // Explicit fallback set catches vars that don't match patterns (e.g. DATABASE_URL). + const SENSITIVE_PATTERNS = /SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL/i; + const SENSITIVE_KEYS = new Set(['DATABASE_URL']); const safeEnv = Object.fromEntries( - Object.entries(process.env as Record).filter(([k]) => !SENSITIVE_KEYS.includes(k)) + Object.entries(process.env as Record).filter( + ([k]) => !SENSITIVE_PATTERNS.test(k) && !SENSITIVE_KEYS.has(k) + ) ); const ptyProcess = pty.spawn(shell, [], { diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx index ff25f3ec..c888e824 100644 --- a/docs/features/host-console.mdx +++ b/docs/features/host-console.mdx @@ -57,6 +57,7 @@ The host console has a stricter authentication requirement than other features: - Requires a valid **browser session** (httpOnly cookie). Node-proxy tokens used for multi-node communication are explicitly blocked. - Each session is issued a short-lived **console token** (60-second TTL) before the WebSocket is established. - Access is restricted to the **Admiral** license tier, ensuring only teams with appropriate licensing can use interactive shell access. +- Sensitive environment variables (names containing SECRET, PASSWORD, TOKEN, KEY, or CREDENTIAL, plus `DATABASE_URL`) are automatically stripped from the console environment so they cannot be read via `env` or `printenv`. - Because the console gives full shell access to the host, you should secure your Sencho instance with HTTPS and a strong password if it is exposed to a network. diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index b8f68b4e..0292eb0b 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -81,9 +81,10 @@ Click the **pencil icon** on any remote node row to edit its name, URL, or token ### Token security -Node tokens are long-lived JWTs that grant full control over the remote Sencho instance. Treat them like passwords: +Node tokens are JWTs with a default **1-year expiry** that grant full control over the remote Sencho instance. Treat them like passwords: - **Rotate immediately** if a token is compromised: Settings → Nodes → Generate Token on the remote instance. The old token is invalidated instantly. +- Tokens **expire after 1 year** by default. Generate a new token on the remote instance to renew access. - Tokens are **encrypted at rest** using AES-256-GCM in Sencho's SQLite database. Even if the database file is extracted, the tokens cannot be read without the instance's encryption key. - Tokens are scoped to `node_proxy` — they cannot access the host console or container exec terminals. Interactive shell access always requires a real browser session on that specific instance. diff --git a/docs/features/webhooks.mdx b/docs/features/webhooks.mdx index 5b5cb9cf..22cdd507 100644 --- a/docs/features/webhooks.mdx +++ b/docs/features/webhooks.mdx @@ -127,5 +127,6 @@ Each webhook tracks its last 100 executions. Click **Recent executions** on any - Webhook secrets are 64-character hex strings generated with `crypto.randomBytes(32)` - Signature validation uses `crypto.timingSafeEqual` to prevent timing attacks - Secrets are shown only once at creation - API responses return masked values +- Signature verification uses the raw request bytes, not re-serialized JSON, to prevent serialization-induced mismatches - Webhook trigger endpoints are public (no session cookie required) but protected by HMAC signature validation - Each webhook targets a single stack - there is no way to execute arbitrary commands