From a92eeceae5093abd03d0501c7af23728ca61cef4 Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 2 Jul 2026 18:27:03 -0700 Subject: [PATCH] DC-029: skip authLimiter for already-authenticated requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashcaddy-api/VERSION | 2 +- .../__tests__/auth-rate-limiter.test.js | 132 +++++++ dashcaddy-api/routes/auth/sso-gate.js | 31 +- dashcaddy-api/src/utilities/middleware.js | 9 +- status/index.html | 41 +++ status/js/tailscale-devices.js | 329 ++++++++++++++++++ 6 files changed, 532 insertions(+), 12 deletions(-) create mode 100644 status/js/tailscale-devices.js 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__ +__TITLE__

__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 @@ +
+ +
+
+ +
+ Tailscale + + +
+
+ Loading… +
+
+ +
+
+
@@ -302,6 +325,21 @@
+ + +
@@ -906,6 +944,9 @@ + + + diff --git a/status/js/tailscale-devices.js b/status/js/tailscale-devices.js new file mode 100644 index 0000000..d880610 --- /dev/null +++ b/status/js/tailscale-devices.js @@ -0,0 +1,329 @@ +// Tailscale device list panel for status.sami +// +// Polls /api/v1/tailscale/status (richer payload: online + offline peers with +// lastSeen, OS, user) every 30s and renders a device card. Clicking the card +// opens a panel that lists every device known to the tailnet — online ones +// first, with "last seen Xm ago" for offline ones. +// +// Self-contained so it doesn't need to be in dist/. Just needs secureFetch, +// escapeHtml, and timeAgo (all from dist/core.js, which loads first). + +(function () { + 'use strict'; + + const REFRESH_MS = 30 * 1000; // status doesn't change fast — 30s is plenty + const STATUS_URL = '/api/v1/tailscale/status'; + const DEVICES_URL = '/api/v1/tailscale/devices'; + + const OS_ICON = { + windows: '🪟', + linux: '🐧', + darwin: '🍎', + android: '🤖', + ios: '📱', + macos: '🍎', + synology:'💾', + other: '🖥️', + }; + + function osKey(raw) { + if (!raw) return 'other'; + const s = String(raw).toLowerCase(); + if (s.includes('windows')) return 'windows'; + if (s.includes('darwin') || s.includes('macos') || s.includes('mac')) return 'darwin'; + if (s.includes('android')) return 'android'; + if (s.includes('ios')) return 'ios'; + if (s.includes('linux')) return 'linux'; + if (s.includes('synology') || s.includes('dsm')) return 'synology'; + return 'other'; + } + + // Module-local cache so the panel render doesn't refetch when the user toggles + // between card and panel view. + let cache = null; // last successful /status payload + let lastFetch = 0; // ms timestamp of last successful fetch + let pollTimer = null; + let inFlight = null; // promise of in-flight fetch (deduplicate overlapping calls) + + function $ (id) { return document.getElementById(id); } + + function setCard (onlineCount, totalCount, connected, installed) { + const card = $('tailscale-card'); + const dot = $('tailscale-dot'); + const pill = $('tailscale-pill'); + const summary = $('tailscale-summary'); + if (!card || !dot || !pill || !summary) return; + + if (!installed) { + card.setAttribute('data-status', 'off'); + dot.className = 'dot bad at-bl'; + pill.className = 'badge off'; + pill.textContent = 'N/A'; + summary.textContent = 'Tailscale not installed'; + return; + } + if (!connected) { + card.setAttribute('data-status', 'off'); + dot.className = 'dot bad at-bl'; + pill.className = 'badge off'; + pill.textContent = 'OFF'; + summary.textContent = 'Daemon offline'; + return; + } + card.setAttribute('data-status', 'on'); + dot.className = 'dot ok at-bl'; + pill.className = 'badge on'; + pill.textContent = onlineCount + '/' + totalCount; + summary.textContent = onlineCount === 1 + ? '1 device online' + : onlineCount + ' devices online'; + } + + function deviceRow (dev) { + const online = !!dev.online; + const isSelf = !!dev.isSelf; + const osIcon = OS_ICON[osKey(dev.os)] || OS_ICON.other; + const host = escapeHtml(dev.hostname || dev.id || '(unknown)'); + const ip = dev.ip ? escapeHtml(dev.ip) : ''; + const user = dev.user ? escapeHtml(String(dev.user).split('@')[0]) : ''; + const lastSeen = (!online && dev.lastSeen) + ? 'last seen ' + escapeHtml(timeAgo(dev.lastSeen)) + '' + : ''; + const selfBadge = isSelf + ? 'THIS DEVICE' + : ''; + + return ` +
+ ${osIcon} +
+
+ ${host}${selfBadge} +
+
+ + + ${online ? 'ONLINE' : 'OFFLINE'} + + ${user ? '·' + user + '' : ''} + ${ip ? '·' + ip + '' : ''} + ${lastSeen} +
+
+
+ `; + } + + function renderPanel (status) { + const list = $('tailscale-device-list'); + const count = $('tailscale-panel-count'); + const tailnet = $('tailscale-tailnet'); + if (!list) return; + + if (!status || !status.installed) { + list.innerHTML = '
Tailscale is not installed on this host.
'; + if (count) count.textContent = ''; + if (tailnet) tailnet.textContent = ''; + return; + } + if (!status.connected) { + list.innerHTML = '
Tailscale daemon is not running. Start it with systemctl start tailscaled.
'; + if (count) count.textContent = ''; + if (tailnet) tailnet.textContent = ''; + return; + } + + // Build merged device list: self first, then online peers, then offline peers + const devices = []; + if (status.self && status.self.ip) { + devices.push({ + id: 'self', hostname: status.self.hostname, ip: status.self.ip, + os: status.self.os, online: !!status.self.online, isSelf: true, + lastSeen: status.self.online ? null : null, + }); + } + const onlinePeers = []; + const offlinePeers = []; + for (const dev of (status.devices || [])) { + if (dev.isSelf) continue; // already added above + const entry = { + id: dev.id, hostname: dev.hostname, ip: dev.ip, os: dev.os, + online: !!dev.online, user: dev.user, + lastSeen: dev.online ? null : dev.lastSeen, + }; + if (entry.online) onlinePeers.push(entry); else offlinePeers.push(entry); + } + // Sort: hostname alpha within each bucket + onlinePeers.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); + offlinePeers.sort((a, b) => { + // Most-recently-seen first for offline + const ta = a.lastSeen ? new Date(a.lastSeen).getTime() : 0; + const tb = b.lastSeen ? new Date(b.lastSeen).getTime() : 0; + return tb - ta; + }); + const sorted = devices.concat(onlinePeers, offlinePeers); + + if (sorted.length === 0) { + list.innerHTML = '
No devices found in this tailnet.
'; + } else { + list.innerHTML = sorted.map(deviceRow).join(''); + } + + const onlineCount = sorted.filter(d => d.online).length; + if (count) { + count.textContent = sorted.length === 1 + ? '1 device total' + : sorted.length + ' devices total · ' + onlineCount + ' online'; + } + if (tailnet) { + const tn = status.self && status.self.tailnetName; + tailnet.textContent = tn ? '(' + tn + ')' : ''; + } + } + + async function fetchStatus (force) { + // Dedupe overlapping calls + skip if we have a fresh cache + const now = Date.now(); + if (!force && cache && (now - lastFetch) < REFRESH_MS) return cache; + if (inFlight) return inFlight; + + inFlight = (async () => { + try { + const res = await secureFetch(STATUS_URL, { cache: 'no-store' }); + if (!res.ok) throw new Error('status ' + res.status); + const data = await res.json(); + cache = data; + lastFetch = Date.now(); + return data; + } catch (e) { + // If /status fails, the container might be on an older build without it. + // Fall back to /devices (smaller payload, online only). + try { + const res2 = await secureFetch(DEVICES_URL, { cache: 'no-store' }); + if (!res2.ok) throw new Error('devices ' + res2.status); + const d = await res2.json(); + cache = { + installed: true, + connected: true, + self: d.devices && d.devices.find(x => x.isSelf), + devices: (d.devices || []).filter(x => !x.isSelf), + }; + lastFetch = Date.now(); + return cache; + } catch (e2) { + // Surface a graceful "unknown" state rather than throwing + if (typeof errorHandler !== 'undefined') { + errorHandler.logError('[Tailscale] Fetch', e2, { function: 'fetchStatus' }); + } + return null; + } + } finally { + inFlight = null; + } + })(); + return inFlight; + } + + async function refresh () { + const status = await fetchStatus(false); + if (!status) { + setCard(0, 0, false, false); + return; + } + const installed = !!status.installed; + const connected = !!status.connected; + const total = (status.devices ? status.devices.length : 0) + (status.self ? 1 : 0); + const online = (status.devices ? status.devices.filter(d => d.online).length : 0) + + (status.self && status.self.online ? 1 : 0); + setCard(online, total, connected, installed); + // Only re-render the panel if it's currently open (saves DOM thrash) + const panel = $('tailscale-panel'); + if (panel && panel.style.display !== 'none') { + renderPanel(status); + } + } + + function togglePanel () { + const panel = $('tailscale-panel'); + if (!panel) return; + const isOpen = panel.style.display !== 'none'; + if (isOpen) { + panel.style.display = 'none'; + } else { + panel.style.display = ''; + // Force a refresh when opening so the user always sees fresh data + fetchStatus(true).then(renderPanel); + } + } + + function init () { + // Card click → toggle panel (skip if click was on the refresh button) + const card = $('tailscale-card'); + if (card) { + card.addEventListener('click', (e) => { + if (e.target && e.target.id === 'tailscale-refresh-btn') return; + togglePanel(); + }); + } + // Cursor hint + if (card) card.style.cursor = 'pointer'; + + // Refresh button → force fetch + const btn = $('tailscale-refresh-btn'); + if (btn) { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + btn.disabled = true; + const orig = btn.innerHTML; + btn.innerHTML = '…'; + try { + const status = await fetchStatus(true); + if (status) { + renderPanel(status); + // Update card too + const total = (status.devices ? status.devices.length : 0) + (status.self ? 1 : 0); + const online = (status.devices ? status.devices.filter(d => d.online).length : 0) + + (status.self && status.self.online ? 1 : 0); + setCard(online, total, status.connected, status.installed); + } + } finally { + btn.innerHTML = orig; + btn.disabled = false; + } + }); + } + + // Close button on panel + const closeBtn = $('tailscale-panel-close'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + const panel = $('tailscale-panel'); + if (panel) panel.style.display = 'none'; + }); + } + + // Initial fetch + periodic poll + refresh(); + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(refresh, REFRESH_MS); + } + + // Re-init if the card is later rendered (e.g. after TOTP auth completes) + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + // Defer slightly to let dist/core.js's secureFetch be defined + setTimeout(init, 0); + } +})();