diff --git a/services/mam-api/src/auth/tokens.js b/services/mam-api/src/auth/tokens.js index 15d2b36..998b57c 100644 --- a/services/mam-api/src/auth/tokens.js +++ b/services/mam-api/src/auth/tokens.js @@ -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); diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index acfd698..47cf8c1 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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) diff --git a/services/mam-api/src/routes/storage.js b/services/mam-api/src/routes/storage.js index 493d87b..d073061 100644 --- a/services/mam-api/src/routes/storage.js +++ b/services/mam-api/src/routes/storage.js @@ -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+/); diff --git a/services/web-ui/nginx.conf b/services/web-ui/nginx.conf index 10db848..a1ebf9a 100644 --- a/services/web-ui/nginx.conf +++ b/services/web-ui/nginx.conf @@ -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