diff --git a/dashcaddy-api/VERSION b/dashcaddy-api/VERSION index 7dbf502..c09e6a3 100644 --- a/dashcaddy-api/VERSION +++ b/dashcaddy-api/VERSION @@ -1 +1 @@ -a5f51e4 +fef7e07 diff --git a/dashcaddy-api/__tests__/auth-rate-limiter.test.js b/dashcaddy-api/__tests__/auth-rate-limiter.test.js index 97c6b33..c545b04 100644 --- a/dashcaddy-api/__tests__/auth-rate-limiter.test.js +++ b/dashcaddy-api/__tests__/auth-rate-limiter.test.js @@ -116,4 +116,136 @@ describe('authLimiter [DC-027] path coverage', () => { expect(RATE_LIMITS.STRICT.max).toBeLessThan(RATE_LIMITS.GENERAL.max); expect(RATE_LIMITS.STRICT.windowMs).toBe(RATE_LIMITS.GENERAL.windowMs); }); +}); + +describe('authLimiter [DC-027] auth-skip regression', () => { + // The DC-027 implementation shipped with `skip: () => isTest`, which + // counts every request — including those from an already-authenticated + // TOTP/JWT/apikey caller. Caddy's forward_auth fires /auth/gate/* on every + // page-load asset (HTML, JS, CSS, XHR), so a normal browser session + // exhausts the 20-req/15-min budget within ~3 page loads and starts + // getting 429. The fix: skip when req.auth?.type is set by the upstream + // jwtApiKeyAuthMiddleware. These tests pin the fix in place so a future + // refactor that drops the skip clause trips a red test. + function buildAppWithSkip(skipFn) { + const app = express(); + const authLimiter = rateLimit({ + ...RATE_LIMITS.STRICT, + standardHeaders: true, + legacyHeaders: false, + skip: skipFn, + message: { success: false, error: 'Too many auth requests' } + }); + app.use('/api/v1/auth/gate', authLimiter); + app.use((req, res, next) => { + // Simulate jwtApiKeyAuthMiddleware populating req.auth + // (production order: totpAuthMiddleware → jwtApiKeyAuthMiddleware → authLimiter) + const sessionCookie = req.headers.cookie || ''; + if (sessionCookie.includes('dashcaddy_session=')) { + req.auth = { type: 'session', scope: ['admin'] }; + } + next(); + }); + app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); + return app; + } + + test('skips when req.auth.type === "session"', async () => { + // tight limiter so we can prove the skip actually fires (otherwise + // STRICT.max=20 would mask the bug — 20 unauth calls would trip it, + // but we want to confirm the 21st authenticated call still passes). + const app = express(); + // Simulate jwtApiKeyAuthMiddleware populating req.auth — must run BEFORE + // the limiter (production order: totpAuthMiddleware → jwtApiKeyAuthMiddleware + // → authLimiter). Use max=3 to confirm the skip actually fires. + app.use((req, res, next) => { + req.auth = { type: 'session', scope: ['admin'] }; + next(); + }); + const tightLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', + message: { success: false, error: 'Too many auth requests' } + }); + app.use('/api/v1/auth/gate', tightLimiter); + app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); + + // 10 calls with a valid session — all should pass thanks to the skip + for (let i = 0; i < 10; i++) { + const res = await request(app).get('/api/v1/auth/gate/plex'); + expect(res.status).toBe(200); + } + }); + + test('skips when req.auth.type === "jwt"', async () => { + const app = express(); + app.use((req, res, next) => { + req.auth = { type: 'jwt', scope: ['admin'] }; + next(); + }); + const tightLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', + }); + app.use('/api/v1/auth/gate', tightLimiter); + app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); + + for (let i = 0; i < 10; i++) { + const res = await request(app).get('/api/v1/auth/gate/plex'); + expect(res.status).toBe(200); + } + }); + + test('skips when req.auth.type === "apikey"', async () => { + const app = express(); + app.use((req, res, next) => { + req.auth = { type: 'apikey', scope: ['read'] }; + next(); + }); + const tightLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', + }); + app.use('/api/v1/auth/gate', tightLimiter); + app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); + + for (let i = 0; i < 10; i++) { + const res = await request(app).get('/api/v1/auth/gate/plex'); + expect(res.status).toBe(200); + } + }); + + test('still counts UNAUTHENTICATED requests (security defense preserved)', async () => { + const app = express(); + // NO auth middleware — req.auth is undefined for every request + const tightLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', + message: { success: false, error: 'Too many auth requests' } + }); + app.use('/api/v1/auth/gate', tightLimiter); + app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); + + // First 3 unauth calls pass + for (let i = 0; i < 3; i++) { + const res = await request(app).get('/api/v1/auth/gate/plex'); + expect(res.status).toBe(200); + } + // 4th unauth call blocked — DC-027 defense still works + const blocked = await request(app).get('/api/v1/auth/gate/plex'); + expect(blocked.status).toBe(429); + expect(blocked.body.error).toMatch(/too many/i); + }); }); \ No newline at end of file diff --git a/dashcaddy-api/routes/auth/sso-gate.js b/dashcaddy-api/routes/auth/sso-gate.js index c6174e3..e43317f 100644 --- a/dashcaddy-api/routes/auth/sso-gate.js +++ b/dashcaddy-api/routes/auth/sso-gate.js @@ -216,8 +216,13 @@ module.exports = function(deps) { }; function buildLoginPage(service) { + // Pre-auth check via so it fires even when JS is + // disabled or blocked. The cookie is sent automatically because we hit the + // same origin (plex.sami); if the API returns 200 the user has a valid + // session and we render the auto-login body; if 401, the meta-refresh kicks + // in and sends them to status.sami to authenticate first. const SHELL = (body) => ` -
__TITLE__
`; const pages = { @@ -237,31 +248,31 @@ d.textContent='Fetching token from DashCaddy...'; ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){ d.textContent+='\\n'+t.substring(0,300); try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')} - else{fail('Auto-login unavailable. Sign in manually','No token field in response')}} - catch(e){fail('Auto-login error. Sign in manually','Parse error: '+e.message)} -}).catch(function(e){fail('Could not reach DashCaddy. Sign in manually','Fetch error: '+e.message)})` + else{fail('Auto-login unavailable. Sign in at DashCaddy','No token field in response')}} + catch(e){fail('Auto-login error. Sign in at DashCaddy','Parse error: '+e.message)} +}).catch(function(e){fail('Could not reach DashCaddy. Sign in at DashCaddy','Fetch error: '+e.message)})` }, plex: { title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d', body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} ft('plex').then(function(r){return r.json()}).then(function(j){ if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1')} - else{fail('Auto-login unavailable. Open Plex manually',JSON.stringify(j))} -}).catch(function(e){fail('Could not reach DashCaddy. Open Plex manually','Error: '+e.message)})` + else{fail('Auto-login unavailable. Open Plex manually or re-authenticate at DashCaddy',JSON.stringify(j))} +}).catch(function(e){fail('Could not reach DashCaddy. Sign in at DashCaddy','Error: '+e.message)})` }, jellyfin: { title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc', body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){ if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/')} - else{fail('Auto-login unavailable. Open Jellyfin manually',JSON.stringify(j))} -}).catch(function(e){fail('Could not reach DashCaddy. Open Jellyfin manually','Error: '+e.message)})` + else{fail('Auto-login unavailable. Open Jellyfin manually or re-authenticate at DashCaddy',JSON.stringify(j))} +}).catch(function(e){fail('Could not reach DashCaddy. Sign in at DashCaddy','Error: '+e.message)})` }, emby: { title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b', body: `ft('emby').then(function(r){return r.json()}).then(function(j){ if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/')} - else{fail('Auto-login unavailable. Open Emby manually',JSON.stringify(j))} -}).catch(function(e){fail('Could not reach DashCaddy. Open Emby manually','Error: '+e.message)})` + else{fail('Auto-login unavailable. Open Emby manually or re-authenticate at DashCaddy',JSON.stringify(j))} +}).catch(function(e){fail('Could not reach DashCaddy. Sign in at DashCaddy','Error: '+e.message)})` }, }; diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 47c9845..8ff15b0 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -490,7 +490,14 @@ module.exports = function configureMiddleware(app, { ...RATE_LIMITS.STRICT, standardHeaders: true, legacyHeaders: false, - skip: () => isTest, + // SECURITY [DC-027]: rate limit credential scraping. Skip when the caller + // is already authenticated — req.auth.type is set by jwtApiKeyAuthMiddleware + // (above this in the chain), so by the time this runs we know whether the + // request came from a logged-in session, JWT, or API key. Without this + // exception, Caddy's forward_auth chatter on every page-load asset + // (HTML, JS, CSS, XHR) burns the budget for legit users — every browser + // session trips 429 within ~3 page loads. + skip: (req) => isTest || req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', message: { success: false, error: 'Too many auth requests, please try again later' } }); app.use('/api/v1/auth/keys', authLimiter); diff --git a/status/index.html b/status/index.html index fb249d7..8f04402 100644 --- a/status/index.html +++ b/status/index.html @@ -276,6 +276,29 @@ +systemctl start tailscaled.