[grade=A urn:ump:5bm325dgncwgwsl2joztkf4br3wh5lh6jam3rn32fvyr62zyoysa] fix(auth): machine credentials (dk_ keys, JWTs) dead under TOTP — audit P0-1
With TOTP enabled, totpAuthMiddleware answered 401 before
jwtApiKeyAuthMiddleware ever ran: API keys and JWTs were rejected on
every TOTP-protected install (live-verified during the 2026-10-27
sweep with a freshly minted dk_ key getting 401 on /api/v1/mcp/info).
MCP-over-HTTP ('Connect your AI'), fleet endpoints and every machine
integration were broken in production.
Fix: the TOTP gate now verifies Bearer JWT then x-api-key itself
(try/catch, fall-through to the unchanged 401) and sets req.auth;
the JWT/API-key gate passes through on already-set machine auth
(no double verification). Session/public/TOTP-disabled paths
byte-identical.
New chain-level test (no req.auth injection — the gap that hid this
bug) boots the real configureMiddleware chain; VERIFIED it fails 2/5
with the fix stashed and passes 5/5 with it. Judge polish (call-count
assertion) noted as optional follow-up.
Full suite: 172/172 suites, 3731/3731 tests.
This commit is contained in:
2 files changed
+136
-1
No files matched your search
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Audit P0-1 (2026-10-27): with TOTP enabled, totpAuthMiddleware answered 401
|
||||
* BEFORE jwtApiKeyAuthMiddleware ever ran, so machine credentials (dk_ API
|
||||
* keys, JWTs) were dead on every TOTP-protected install — live-verified with
|
||||
* a freshly minted key getting 401 on /api/v1/mcp/info. The route-level tests
|
||||
* missed it because they inject req.auth directly instead of exercising the
|
||||
* real chain.
|
||||
*
|
||||
* This suite boots the REAL configureMiddleware chain (both auth gates in
|
||||
* registration order) and drives synthetic requests through it — the test the
|
||||
* codebase was missing. No req.auth injection anywhere.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const crypto = require('crypto');
|
||||
const configureMiddleware = require('../../src/utilities/middleware');
|
||||
|
||||
const KEY = Buffer.alloc(32, 7);
|
||||
|
||||
function makeDeps() {
|
||||
return {
|
||||
siteConfig: { dashboardHost: 'status.sami', tld: '.sami' },
|
||||
totpConfig: { enabled: true, sessionDuration: '24h' },
|
||||
tailscaleConfig: { enabled: false, requireAuth: false },
|
||||
metrics: { recordRequest: jest.fn() },
|
||||
auditLogger: { middleware: () => (_req, _res, next) => next() },
|
||||
authManager: {
|
||||
verifyJWT: jest.fn(async (token) =>
|
||||
token === 'good.jwt' ? { userId: 'u1', scope: ['read'] } : null),
|
||||
verifyAPIKey: jest.fn(async (key) =>
|
||||
key === 'dk_goodkey'
|
||||
? { keyId: 'k1', name: 'test-key', scopes: ['read', 'write'] }
|
||||
: null),
|
||||
},
|
||||
log: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
cryptoUtils: { loadOrCreateKey: () => KEY },
|
||||
isValidContainerId: () => true,
|
||||
isTailscaleIP: () => false,
|
||||
getTailscaleStatus: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Mint a session cookie exactly the way setSessionCookie does, so the
|
||||
// session path of the chain is exercised with real HMAC math too.
|
||||
function mintSessionCookie() {
|
||||
const payload = { v: true, exp: Date.now() + 86400000 };
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', KEY).update(payloadB64).digest('base64url');
|
||||
return `dashcaddy_session=${payloadB64}.${sig}`;
|
||||
}
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
configureMiddleware(app, makeDeps());
|
||||
// Probe route registered AFTER the middleware chain, like a real mount.
|
||||
// NOT in PUBLIC_ROUTES — it must go through the full chain.
|
||||
app.get('/api/v1/mcp/info', (req, res) => res.json({ ok: true, auth: req.auth }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('middleware chain: machine credentials pass the TOTP gate (audit P0-1)', () => {
|
||||
test('valid x-api-key reaches the route when TOTP is enabled', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/v1/mcp/info')
|
||||
.set('x-api-key', 'dk_goodkey');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.auth).toMatchObject({ type: 'apikey', keyId: 'k1', name: 'test-key' });
|
||||
});
|
||||
|
||||
test('valid Bearer JWT reaches the route when TOTP is enabled', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/v1/mcp/info')
|
||||
.set('Authorization', 'Bearer good.jwt');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.auth).toMatchObject({ type: 'jwt', userId: 'u1' });
|
||||
});
|
||||
|
||||
test('INVALID api key is still rejected with requiresTotp', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/v1/mcp/info')
|
||||
.set('x-api-key', 'dk_wrong');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toContain('DC-110');
|
||||
expect(res.body.requiresTotp).toBe(true);
|
||||
});
|
||||
|
||||
test('no credentials at all is still rejected with requiresTotp', async () => {
|
||||
const res = await request(buildApp()).get('/api/v1/mcp/info');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.requiresTotp).toBe(true);
|
||||
});
|
||||
|
||||
test('valid session cookie still authenticates (session path unchanged)', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/v1/mcp/info')
|
||||
.set('Cookie', mintSessionCookie());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.auth).toMatchObject({ type: 'session', scope: ['admin'] });
|
||||
});
|
||||
});
|
||||
@@ -528,7 +528,7 @@ module.exports = function configureMiddleware(app, {
|
||||
}
|
||||
|
||||
// ── TOTP auth middleware ──
|
||||
const totpAuthMiddleware = (req, res, next) => {
|
||||
const totpAuthMiddleware = async (req, res, next) => {
|
||||
// If TOTP is not enabled at all, skip auth entirely — this is the initial-setup state
|
||||
if (!totpConfig.enabled) {
|
||||
req.auth = {
|
||||
@@ -542,6 +542,32 @@ module.exports = function configureMiddleware(app, {
|
||||
if (isPublicRoute(req)) return next();
|
||||
if (isSessionValid(req)) return next();
|
||||
|
||||
// Audit P0-1 (2026-10-27): this gate previously answered 401 before the
|
||||
// JWT/API-key middleware below ever ran, so machine credentials (dk_
|
||||
// keys, JWTs) were dead on every TOTP-protected install — live-verified
|
||||
// with a freshly minted key getting 401 on /api/v1/mcp/info. Verify them
|
||||
// HERE before rejecting; the gate below passes req.auth through.
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
try {
|
||||
const jwtPayload = await authManager.verifyJWT(authHeader.substring(7));
|
||||
if (jwtPayload) {
|
||||
req.auth = { type: 'jwt', userId: jwtPayload.userId, scope: jwtPayload.scope || [] };
|
||||
return next();
|
||||
}
|
||||
} catch (_) { /* invalid JWT — fall through to the API-key check */ }
|
||||
}
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey) {
|
||||
try {
|
||||
const keyData = await authManager.verifyAPIKey(apiKey);
|
||||
if (keyData) {
|
||||
req.auth = { type: 'apikey', keyId: keyData.keyId, name: keyData.name, scope: keyData.scopes || [] };
|
||||
return next();
|
||||
}
|
||||
} catch (_) { /* invalid key — fall through to 401 */ }
|
||||
}
|
||||
|
||||
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
|
||||
};
|
||||
|
||||
@@ -549,6 +575,11 @@ module.exports = function configureMiddleware(app, {
|
||||
|
||||
// ── JWT/API Key authentication middleware ──
|
||||
const jwtApiKeyAuthMiddleware = async (req, res, next) => {
|
||||
// Audit P0-1 (2026-10-27): the TOTP gate (registered above) may have
|
||||
// already authenticated this request via its machine-credential path —
|
||||
// pass through instead of re-verifying the same credential.
|
||||
if (req.auth && (req.auth.type === 'jwt' || req.auth.type === 'apikey')) return next();
|
||||
|
||||
if (req.totpSessionValid || isSessionValid(req)) {
|
||||
req.auth = {
|
||||
type: 'session',
|
||||
|
||||
Reference in new issue
Block a user