From b4022288dc6d827d525deeb9c05b0edf3da518a2 Mon Sep 17 00:00:00 2001 From: Sami Date: Mon, 23 Mar 2026 13:39:05 -0700 Subject: [PATCH] fix: service edit, CSRF token stability, and license restore (v1.1.1) - Fix service edit double-write bug (was creating duplicate entries) - Add editable display name field to service edit modal - Backend update endpoint now accepts name, logo, and recalculates url - Fix CSRF token regeneration breaking all POST requests (nonce was being regenerated on every request, invalidating cached tokens) - CSRF nonce now persists across requests, rotated only on TOTP login - Frontend secureFetch auto-retries on CSRF failure with fresh token - Restore lifetime license activation on DNS2 Co-Authored-By: Claude Opus 4.6 --- dashcaddy-api/.license-counter | 2 +- dashcaddy-api/csrf-protection.js | 56 ++++++++++++++++++++-------- dashcaddy-api/package.json | 2 +- dashcaddy-api/routes/auth/totp.js | 7 +++- dashcaddy-api/routes/services.js | 19 +++++++--- status/js/core/service-crud.js | 62 +++++++++++-------------------- status/js/core/service-modals.js | 11 +++++- status/js/globals.js | 29 +++++++++++++-- status/js/totp-auth.js | 4 ++ 9 files changed, 122 insertions(+), 70 deletions(-) diff --git a/dashcaddy-api/.license-counter b/dashcaddy-api/.license-counter index 56a6051..d8263ee 100644 --- a/dashcaddy-api/.license-counter +++ b/dashcaddy-api/.license-counter @@ -1 +1 @@ -1 \ No newline at end of file +2 \ No newline at end of file diff --git a/dashcaddy-api/csrf-protection.js b/dashcaddy-api/csrf-protection.js index 2e7661b..ee8dbf6 100644 --- a/dashcaddy-api/csrf-protection.js +++ b/dashcaddy-api/csrf-protection.js @@ -49,31 +49,56 @@ function parseCookie(cookieHeader) { } /** - * Middleware to set CSRF cookie on all requests. - * Always generates a fresh nonce server-side (never trusts client-supplied values). - * The cookie holds the nonce; JavaScript must read it and send the HMAC signature - * in the x-csrf-token header. The /api/csrf-token endpoint provides the signature. + * Middleware to set CSRF cookie on requests. + * Preserves existing nonce to avoid invalidating tokens the client has cached. + * New nonce is generated only on first visit (no cookie) or after TOTP login + * (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once + * and never changes. */ function csrfCookieMiddleware(req, res, next) { - // Always generate a fresh server-side nonce - const csrfNonce = generateToken(); + const cookies = parseCookie(req.headers.cookie); + const existingNonce = cookies[CSRF_COOKIE_NAME]; + + // Reuse existing nonce; only generate fresh if no cookie exists yet + const csrfNonce = existingNonce || generateToken(); // Store nonce + signature on request so endpoints can access them req.csrfToken = signToken(csrfNonce); req.csrfNonce = csrfNonce; - // Set cookie with the nonce (SameSite=Strict for additional protection) - res.cookie(CSRF_COOKIE_NAME, csrfNonce, { - httpOnly: false, // Must be readable by JavaScript for signing - secure: req.secure || req.protocol === 'https', // Only secure in HTTPS - sameSite: 'strict', - path: '/', - maxAge: 24 * 60 * 60 * 1000 // 24 hours - }); + // Only set cookie if it's new (avoids unnecessary Set-Cookie headers) + if (!existingNonce) { + res.cookie(CSRF_COOKIE_NAME, csrfNonce, { + httpOnly: false, // Must be readable by JavaScript for signing + secure: req.secure || req.protocol === 'https', + sameSite: 'strict', + path: '/', + maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent) + }); + } next(); } +/** + * Generate a fresh CSRF nonce and set it on the response. + * Called after TOTP login to rotate the token for the new session. + * @param {Object} res - Express response object + * @param {boolean} secure - Whether to set Secure flag on cookie + * @returns {string} The new CSRF signed token + */ +function renewCSRFToken(res, secure) { + const csrfNonce = generateToken(); + res.cookie(CSRF_COOKIE_NAME, csrfNonce, { + httpOnly: false, + secure: !!secure, + sameSite: 'strict', + path: '/', + maxAge: 365 * 24 * 60 * 60 * 1000 + }); + return signToken(csrfNonce); +} + /** * Middleware to validate CSRF token on state-changing requests * Validates that the token in the cookie matches the token in the header @@ -174,5 +199,6 @@ module.exports = { signToken, parseCookie, csrfCookieMiddleware, - csrfValidationMiddleware + csrfValidationMiddleware, + renewCSRFToken }; diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 9c01953..e5598fb 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.1.0", + "version": "1.1.1", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "scripts": { diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index d899577..9439949 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -1,4 +1,5 @@ const express = require('express'); +const { renewCSRFToken } = require('../../csrf-protection'); module.exports = function(ctx) { const router = express.Router(); @@ -104,8 +105,12 @@ module.exports = function(ctx) { ctx.log.info('auth', 'TOTP verified, creating session', { ip: ctx.session.getClientIP(req), duration: ctx.totpConfig.sessionDuration }); ctx.session.create(req, ctx.totpConfig.sessionDuration); ctx.session.setCookie(res, ctx.totpConfig.sessionDuration); + + // Rotate CSRF token for the new session + const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https'); + ctx.log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size }); - res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration }); + res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken }); }, 'totp-verify')); // Check session validity (used by Caddy forward_auth) diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index d06836d..06fcac8 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -371,9 +371,9 @@ module.exports = function(ctx) { res.json({ success: true, message: `Service "${id}" removed from dashboard` }); }, 'services-delete')); - // Update service configuration (subdomain, port, IP, tailscale) + // Update service configuration (subdomain, port, IP, tailscale, name, logo) router.post('/services/update', ctx.asyncHandler(async (req, res) => { - const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly } = req.body; + const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body; if (!oldSubdomain || !newSubdomain) { return ctx.errorResponse(res, 400, 'oldSubdomain and newSubdomain are required'); @@ -440,13 +440,20 @@ module.exports = function(ctx) { await ctx.servicesStateManager.update(services => { const serviceIndex = services.findIndex(s => s.id === oldSubdomain); if (serviceIndex !== -1) { + const existing = services[serviceIndex]; + const finalPort = port || existing.port; + const finalIp = ip || existing.ip; + services[serviceIndex] = { - ...services[serviceIndex], + ...existing, id: newSubdomain, - port: port || services[serviceIndex].port, - ip: ip || services[serviceIndex].ip, - tailscaleOnly: tailscaleOnly || false + port: finalPort, + ip: finalIp, + tailscaleOnly: tailscaleOnly || false, + url: ctx.buildServiceUrl(newSubdomain) }; + if (name) services[serviceIndex].name = name; + if (logo) services[serviceIndex].logo = logo; results.services = 'updated'; } else { results.services = 'not found'; diff --git a/status/js/core/service-crud.js b/status/js/core/service-crud.js index 13c7d3b..0566de1 100644 --- a/status/js/core/service-crud.js +++ b/status/js/core/service-crud.js @@ -10,7 +10,7 @@ const modal = document.getElementById('service-edit-modal'); document.getElementById('service-edit-title').textContent = `Edit ${service.name}`; - document.getElementById('edit-service-name-display').textContent = service.name; + document.getElementById('edit-service-name').value = service.name; document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id); document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`; document.getElementById('edit-subdomain').value = service.id; @@ -31,6 +31,7 @@ if (!currentEditService) return; const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase(); + const newName = document.getElementById('edit-service-name').value.trim(); const newPort = document.getElementById('edit-port').value.trim(); const newIp = document.getElementById('edit-ip').value.trim() || 'localhost'; const tailscaleOnly = document.getElementById('edit-tailscale-only').checked; @@ -45,10 +46,11 @@ const changes = []; if (newSubdomain !== oldSubdomain) changes.push('subdomain'); + if (newName && newName !== currentEditService.name) changes.push('name'); if (newPort && newPort !== String(currentEditService.port)) changes.push('port'); if (newIp !== currentEditService.ip) changes.push('ip'); if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale'); - if (newLogo !== currentEditService.logo) changes.push('logo'); + if (newLogo && newLogo !== currentEditService.logo) changes.push('logo'); if (changes.length === 0) { closeServiceEditModal(); @@ -60,31 +62,32 @@ saveBtn.disabled = true; try { - if (changes.includes('subdomain') || changes.includes('port') || changes.includes('ip') || changes.includes('tailscale')) { - const response = await secureFetch('/api/v1/services/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - oldSubdomain, - newSubdomain, - port: newPort || currentEditService.port, - ip: newIp, - tailscaleOnly - }) - }); + const response = await secureFetch('/api/v1/services/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + oldSubdomain, + newSubdomain, + name: newName || currentEditService.name, + port: newPort || currentEditService.port, + ip: newIp, + tailscaleOnly, + logo: newLogo || undefined + }) + }); - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to update service'); - } + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to update service'); } - // Update local APPS array + // Update local APPS array to match const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain); if (appIndex !== -1) { window.APPS[appIndex] = { ...window.APPS[appIndex], id: newSubdomain, + name: newName || window.APPS[appIndex].name, port: newPort || window.APPS[appIndex].port, ip: newIp, tailscaleOnly, @@ -92,27 +95,6 @@ }; } - // Update services via API - await secureFetch('/api/v1/services', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - id: newSubdomain, - name: currentEditService.name, - port: newPort || currentEditService.port, - ip: newIp, - logo: newLogo || currentEditService.logo, - tailscaleOnly, - containerId: currentEditService.containerId, - appTemplate: currentEditService.appTemplate - }) - }); - - // If subdomain changed, remove old entry - if (newSubdomain !== oldSubdomain) { - await secureFetch(`/api/v1/services/${oldSubdomain}`, { method: 'DELETE' }); - } - closeServiceEditModal(); window.buildGrid(); window.refreshAll(); diff --git a/status/js/core/service-modals.js b/status/js/core/service-modals.js index 47a2e37..3c3209c 100644 --- a/status/js/core/service-modals.js +++ b/status/js/core/service-modals.js @@ -12,12 +12,19 @@
-
-
+
+ +
+ + +
+