fix(audit): critical security hardening and ops reliability fixes
This commit is contained in:
parent
30328e2871
commit
b766f4cdfb
4 changed files with 57 additions and 13 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const PREFIX = 'dfl_';
|
||||
|
||||
|
|
@ -10,6 +10,14 @@ export function hashToken(token) {
|
|||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function compareTokens(tokenA, tokenB) {
|
||||
if (!tokenA || !tokenB) return false;
|
||||
const a = Buffer.from(tokenA);
|
||||
const b = Buffer.from(tokenB);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function parseBearer(authorizationHeader) {
|
||||
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
||||
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ async function requireRecorderEdit(req, res, next) {
|
|||
const SIDECAR_PORT_BASE = 7438;
|
||||
|
||||
// Docker API helper function
|
||||
function dockerApi(method, path, body = null) {
|
||||
function dockerApi(method, path, body = null, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
|
|
@ -60,9 +60,9 @@ function dockerApi(method, path, body = null) {
|
|||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy(new Error('Docker API timeout after 10s'));
|
||||
// Use parameterizable timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`Docker API timeout after ${timeoutMs/1000}s`));
|
||||
});
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
|
|
@ -796,13 +796,16 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
|||
const containerId = recorder.container_id;
|
||||
(async () => {
|
||||
try {
|
||||
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`);
|
||||
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`, null, 185000);
|
||||
if (stopRes.status !== 404) {
|
||||
await waitForFinalize(recorder);
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[recorders] failed local background stop:', e.message);
|
||||
// Attempt finalize and cleanup even if stop call timed out
|
||||
await waitForFinalize(recorder).catch(() => {});
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
@ -997,10 +1000,14 @@ router.post('/probe', async (req, res) => {
|
|||
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
|
||||
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
|
||||
}
|
||||
// Non-admin users can only probe public hostnames. Admins may probe LAN.
|
||||
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
||||
}
|
||||
// Non-admin users can only probe public hostnames. Admins may probe LAN.
|
||||
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
||||
}
|
||||
|
||||
// Probe target should not be mam-api itself.
|
||||
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
|
||||
return res.status(403).json({ error: 'Internal probe target is not permitted' });
|
||||
}
|
||||
|
||||
// Try the capture service first (5s timeout)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ async function probeGrowingPath(path) {
|
|||
|
||||
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
||||
try {
|
||||
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 });
|
||||
const { stdout } = await exec(`df -PB1 -- ${JSON.stringify(path)}`, { timeout: 3000 });
|
||||
const lines = stdout.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const cols = lines[1].split(/\s+/);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,19 @@ server {
|
|||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
# Tighten CORS: no wildcard.
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
add_header Access-Control-Allow-Credentials "true";
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain; charset=utf-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# Playout HLS preview — CasparCG sidecar writes to the media volume under
|
||||
|
|
@ -83,7 +95,19 @@ server {
|
|||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
# Tighten CORS: no wildcard.
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
add_header Access-Control-Allow-Credentials "true";
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain; charset=utf-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# API proxy - forward to mam-api service
|
||||
|
|
@ -133,6 +157,11 @@ server {
|
|||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;" always;
|
||||
}
|
||||
|
||||
# Deny access to dotfiles
|
||||
|
|
|
|||
Loading…
Reference in a new issue