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_';
|
const PREFIX = 'dfl_';
|
||||||
|
|
||||||
|
|
@ -10,6 +10,14 @@ export function hashToken(token) {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
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) {
|
export function parseBearer(authorizationHeader) {
|
||||||
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
||||||
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ async function requireRecorderEdit(req, res, next) {
|
||||||
const SIDECAR_PORT_BASE = 7438;
|
const SIDECAR_PORT_BASE = 7438;
|
||||||
|
|
||||||
// Docker API helper function
|
// Docker API helper function
|
||||||
function dockerApi(method, path, body = null) {
|
function dockerApi(method, path, body = null, timeoutMs = 10000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const options = {
|
const options = {
|
||||||
socketPath: '/var/run/docker.sock',
|
socketPath: '/var/run/docker.sock',
|
||||||
|
|
@ -60,9 +60,9 @@ function dockerApi(method, path, body = null) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
// Use parameterizable timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||||
req.setTimeout(10000, () => {
|
req.setTimeout(timeoutMs, () => {
|
||||||
req.destroy(new Error('Docker API timeout after 10s'));
|
req.destroy(new Error(`Docker API timeout after ${timeoutMs/1000}s`));
|
||||||
});
|
});
|
||||||
if (body) req.write(JSON.stringify(body));
|
if (body) req.write(JSON.stringify(body));
|
||||||
req.end();
|
req.end();
|
||||||
|
|
@ -796,13 +796,16 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
const containerId = recorder.container_id;
|
const containerId = recorder.container_id;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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) {
|
if (stopRes.status !== 404) {
|
||||||
await waitForFinalize(recorder);
|
await waitForFinalize(recorder);
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[recorders] failed local background stop:', e.message);
|
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(() => {});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
@ -1001,6 +1004,10 @@ router.post('/probe', async (req, res) => {
|
||||||
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||||
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
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)
|
// 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"
|
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
||||||
try {
|
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');
|
const lines = stdout.trim().split('\n');
|
||||||
if (lines.length >= 2) {
|
if (lines.length >= 2) {
|
||||||
const cols = lines[1].split(/\s+/);
|
const cols = lines[1].split(/\s+/);
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,19 @@ server {
|
||||||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" 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
|
# 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; }
|
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" 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
|
# API proxy - forward to mam-api service
|
||||||
|
|
@ -133,6 +157,11 @@ server {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
expires -1;
|
expires -1;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
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
|
# Deny access to dotfiles
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue