DC-029: skip authLimiter for already-authenticated requests
The DC-027 rate limiter on /api/v1/auth/* shipped with skip: () => isTest,
which counted every request — including those from a logged-in TOTP session.
Caddy's forward_auth fires /auth/gate/* on every page-load asset (HTML, JS,
CSS, XHR), so a normal browser session exhausted the 20-req/15-min budget
within ~3 page loads and started getting 429 'Too many auth requests' even
with a valid session cookie.
Fix: extend skip to also return true when req.auth.type is 'session',
'jwt', or 'apikey' (set by jwtApiKeyAuthMiddleware, which runs upstream
of the limiter). The unauthenticated path is still rate-limited — DC-027's
credential-scraping defense is preserved.
Also closes the uncommitted working-tree changes for:
- DC-026: routes/auth/sso-gate.js — pre-auth check in buildLoginPage,
redirected error fallbacks to status.sami?auth=required&return=...
- DC-022: dashcaddy-api/VERSION bumped to fef7e07
- status/index.html + status/js/tailscale-devices.js — Tailscale device card
4 new regression tests pin the fix:
- skips when req.auth.type === 'session'
- skips when req.auth.type === 'jwt'
- skips when req.auth.type === 'apikey'
- still counts UNAUTHENTICATED requests (defense preserved)
Live verified: 50/50 authenticated /auth/gate/plex calls passed (was
20/30 before fix). plex.sami/dashcaddy-login returns 200 with no redirect
loop. Plex auto-login token round-trips end-to-end.
This commit is contained in:
@@ -1 +1 @@
|
||||
a5f51e4
|
||||
fef7e07
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -216,8 +216,13 @@ module.exports = function(deps) {
|
||||
};
|
||||
|
||||
function buildLoginPage(service) {
|
||||
// Pre-auth check via <meta http-equiv="refresh"> 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) => `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>__TITLE__</title>
|
||||
<html><head><meta charset="utf-8"><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
|
||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-space:pre-wrap}</style>
|
||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||
<script>(function(){
|
||||
@@ -226,7 +231,13 @@ function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
|
||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
|
||||
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
||||
// Pre-check session before attempting auto-login. If the user is not logged
|
||||
// in, redirect to status.sami for TOTP auth first. The return= param sends
|
||||
// them back to this login page after authenticating so auto-login can run.
|
||||
fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store'}).then(function(r){return r.json()}).then(function(st){
|
||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
||||
${body}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+e.message)})
|
||||
})()</script></body></html>`;
|
||||
|
||||
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. <a href="/auth?nologin=1">Sign in manually</a>','No token field in response')}}
|
||||
catch(e){fail('Auto-login error. <a href="/auth?nologin=1">Sign in manually</a>','Parse error: '+e.message)}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/auth?nologin=1">Sign in manually</a>','Fetch error: '+e.message)})`
|
||||
else{fail('Auto-login unavailable. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','No token field in response')}}
|
||||
catch(e){fail('Auto-login error. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Parse error: '+e.message)}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','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. <a href="/web/?direct=1">Open Plex manually</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+e.message)})`
|
||||
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','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. <a href="/web/">Open Jellyfin manually</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+e.message)})`
|
||||
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','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. <a href="/web/">Open Emby manually</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+e.message)})`
|
||||
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user