From 88b2ad298029a250a35ca5f1f79c08519e109b47 Mon Sep 17 00:00:00 2001 From: Cayce Date: Sat, 31 Oct 2026 17:39:38 +0000 Subject: [PATCH] =?UTF-8?q?hardcap:=20auth=20middleware=20en=20/admin=20?= =?UTF-8?q?=E2=80=94=20token=20obligatorio,=20comparaci=C3=B3n=20en=20tiem?= =?UTF-8?q?po=20constante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adminAuth: exige Authorization: Bearer (o X-Admin-Token). Comparación timingSafeEqual sobre sha256 de ambos valores (sin leak de longitud ni timing). Fail-closed: sin token configurado, TODAS las rutas admin responden 503. - 401s ahora se loguean con método, ruta e IP (primera trazabilidad de acceso que tiene el admin API desde que existe). - token generado (32 bytes hex) y almacenado SOLO en /srv/hardcap-backend/.env — nunca impreso ni versionado. - test-admin-auth.mjs: 6 tests (sin token, incorrecto, vacío, correcto, header alternativo, misma-longitud-distinto) — todos pasan en la VPS. - desplegado en src + dist; servicio reiniciado y activo. Nota de contexto: el admin API carecía de auth desde su creación (index.ts: \x27TODO: add auth middleware before production\x27) y estuvo alcanzable sin credenciales en :3001 hasta la contención de hoy. --- runtime/deploy.sh | 3 +++ runtime/hardcap/admin.ts | 23 +++++++++++++++++++++ runtime/hardcap/test-admin-auth.mjs | 31 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 runtime/hardcap/test-admin-auth.mjs diff --git a/runtime/deploy.sh b/runtime/deploy.sh index f906218..85f4c96 100755 --- a/runtime/deploy.sh +++ b/runtime/deploy.sh @@ -25,5 +25,8 @@ for ENVF in /srv/cayce-core/.env /srv/cayce-nostr/.env /home/ubuntu/cayce-social grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF" done +sudo install -m 644 '$SRC/hardcap/admin.ts' /srv/hardcap-backend/src/routes/admin.ts 2>/dev/null || true +install -m 755 '$SRC/hardcap/test-admin-auth.mjs' /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs 2>/dev/null || true +sudo systemctl restart hardcap-backend sudo systemctl restart cayce-nostr echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)" diff --git a/runtime/hardcap/admin.ts b/runtime/hardcap/admin.ts index f998953..e8758ca 100644 --- a/runtime/hardcap/admin.ts +++ b/runtime/hardcap/admin.ts @@ -4,6 +4,29 @@ import { ingestAllSources } from '../services/ingestion' const router = Router() +// ── auth middleware (2026-10-31): token obligatorio, comparación en tiempo constante. +// Fail-closed: sin HARDCAP_ADMIN_TOKEN configurado, TODAS las rutas admin responden 503. +import { createHash, timingSafeEqual } from 'crypto' + +const ADMIN_TOKEN = process.env.HARDCAP_ADMIN_TOKEN || '' +function _safeEqual(a: string, b: string): boolean { + // hash de ambos a longitud fija: sin leak de longitud ni de timing por contenido + return timingSafeEqual(createHash('sha256').update(String(a)).digest(), createHash('sha256').update(String(b)).digest()) +} +function adminAuth(req: any, res: any, next: any) { + if (!ADMIN_TOKEN) { + console.error('[admin-auth] HARDCAP_ADMIN_TOKEN not set — admin request REFUSED (fail-closed)') + return res.status(503).json({ error: 'admin disabled: token not configured' }) + } + const provided = String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') || String(req.headers['x-admin-token'] || '') + if (provided && _safeEqual(provided, ADMIN_TOKEN)) return next() + console.error(`[admin-auth] 401 ${req.method} ${req.originalUrl} from ${req.socket.remoteAddress}`) + return res.status(401).json({ error: 'unauthorized' }) +} +router.use(adminAuth) +// regla permanente de secretos: jamás loguear el token ni volcar headers. + + // ── Articles ────────────────────────────────────────────── // GET /admin/articles?status=QUEUED&topic=lightning diff --git a/runtime/hardcap/test-admin-auth.mjs b/runtime/hardcap/test-admin-auth.mjs new file mode 100644 index 0000000..3697e51 --- /dev/null +++ b/runtime/hardcap/test-admin-auth.mjs @@ -0,0 +1,31 @@ +// test-admin-auth — verifica el middleware de /admin (hardcap-backend). +// Ejecutar en la VPS: node /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs +// (o node runtime/hardcap/test-admin-auth.mjs desde el repo) +// Requiere HARDCAP_ADMIN_TOKEN en el .env del servicio y el servicio activo en 127.0.0.1:3001. +import { readFileSync } from 'node:fs' + +const BASE = 'http://127.0.0.1:3001' +const env = Object.fromEntries( + readFileSync('/srv/hardcap-backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]) +) +const TOKEN = env.HARDCAP_ADMIN_TOKEN +if (!TOKEN) { console.error('HARDCAP_ADMIN_TOKEN no configurado — el test no puede pasar'); process.exit(2) } + +let failed = 0 +async function check(name, path, opts, expected) { + const r = await fetch(BASE + path, { ...opts, signal: AbortSignal.timeout(8000) }) + const ok = r.status === expected + if (!ok) failed++ + console.log(`${ok ? '✓' : '✗'} ${name}: esperado ${expected}, obtenido ${r.status}`) +} + +await check('sin token → 401', '/admin/articles?status=QUEUED', {}, 401) +await check('token incorrecto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'x'.repeat(64) } }, 401) +await check('token vacío en header → 401', '/admin/sources', { headers: { Authorization: 'Bearer ' } }, 401) +await check('token correcto → 200', '/admin/articles?status=QUEUED', { headers: { Authorization: `Bearer ${TOKEN}` } }, 200) +await check('header alternativo X-Admin-Token → 200', '/admin/sources', { headers: { 'x-admin-token': TOKEN } }, 200) +// la comparación debe ser en tiempo constante: un token de la misma longitud pero distinto NO pasa +await check('token misma longitud, distinto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'y'.repeat(TOKEN.length) } }, 401) + +console.log(failed ? `\n${failed} TESTS FALLARON` : '\nTODOS LOS TESTS PASARON') +process.exit(failed ? 1 : 0)