mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
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)
This commit is contained in:
+14
-6
@@ -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' });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string>).filter(([k]) => !SENSITIVE_KEYS.includes(k))
|
||||
Object.entries(process.env as Record<string, string>).filter(
|
||||
([k]) => !SENSITIVE_PATTERNS.test(k) && !SENSITIVE_KEYS.has(k)
|
||||
)
|
||||
);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
|
||||
Reference in New Issue
Block a user