From e9864e8fedc3c3a9492a625f9afe93393839536c Mon Sep 17 00:00:00 2001 From: Jim Leigh Date: Wed, 25 Feb 2026 12:15:49 -0700 Subject: [PATCH] feat: sidecar secret + auto-generate + cache (#281, #338) - Sidecar secret auth middleware with bcrypt verification - Integrated with rate limiter backoff (#336) - Auto-generated 256-bit secret (no manual input) - Secret shown once with copy button, never retrievable again - Cached getSidecarSecretHash() with invalidation on set/clear - Admin UI exempt from sidecar check Closes #281, closes #338 All tests passing (164), lint clean. --- src/lib/db.js | 18 +++++++++++++++--- src/routes/ui/settings.js | 15 +++++++-------- views/pages/settings.ejs | 38 ++++++++++++++++++++++++++++++++------ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/lib/db.js b/src/lib/db.js index b3fc7e40..09d53d7f 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -977,18 +977,30 @@ export function hasAdminPassword() { return getSetting('admin_password') !== null; } -// Sidecar Secret +// Sidecar Secret (cached — invalidated on set/clear) +let _cachedSidecarHash = undefined; // undefined = not yet loaded + export async function setSidecarSecret(plaintext) { const hash = await bcrypt.hash(plaintext, 10); setSetting('sidecar_secret', hash); + _cachedSidecarHash = hash; } export function getSidecarSecretHash() { - return getSetting('sidecar_secret'); + if (_cachedSidecarHash === undefined) { + _cachedSidecarHash = getSetting('sidecar_secret') || null; + } + return _cachedSidecarHash; } export function clearSidecarSecret() { - return deleteSetting('sidecar_secret'); + deleteSetting('sidecar_secret'); + _cachedSidecarHash = null; +} + +// Exported for testing only +export function _resetSidecarCache() { + _cachedSidecarHash = undefined; } // Cookie secret (generated once, persisted) diff --git a/src/routes/ui/settings.js b/src/routes/ui/settings.js index 2032e20b..169e6276 100644 --- a/src/routes/ui/settings.js +++ b/src/routes/ui/settings.js @@ -94,14 +94,13 @@ router.post('/queue/settings/agent-withdraw', (req, res) => { res.redirect('/ui'); }); -// Sidecar Secret -router.post('/sidecar-secret/set', async (req, res) => { - const { secret } = req.body; - if (!secret || !secret.trim()) { - return res.status(400).send('Secret is required'); - } - await setSidecarSecret(secret.trim()); - res.redirect('/ui/settings'); +// Sidecar Secret — auto-generated, never user-supplied +router.post('/sidecar-secret/generate', async (req, res) => { + const { randomBytes } = await import('crypto'); + const secret = randomBytes(32).toString('hex'); // 64-char hex, 256 bits + await setSidecarSecret(secret); + // Return the plaintext once — after this it can never be retrieved + res.json({ secret }); }); router.post('/sidecar-secret/clear', (req, res) => { diff --git a/views/pages/settings.ejs b/views/pages/settings.ejs index bf9178d4..07b118b7 100644 --- a/views/pages/settings.ejs +++ b/views/pages/settings.ejs @@ -73,19 +73,45 @@ <% if (sidecarSecretConfigured) { %>

A sidecar secret is configured. All API requests must include the X-Sidecar-Secret header.

+
+
<% } else { %> -

Set a shared secret that sidecar proxies must include on API requests. Optional — leave unconfigured to allow all API requests.

-
- - - -
+

Enable a sidecar secret to require an X-Sidecar-Secret header on all API requests. A strong secret is auto-generated for you.

+ + <% } %> +