From a3c29c057be272e9106dc620808643d417b6e858 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 23 Feb 2026 17:19:03 -0600 Subject: [PATCH] Security hardening: remove hardcoded secrets, fix XSS, flash-based secret display - Remove hardcoded fallback values for OIDC_KEY_SECRET, MFA_ENCRYPTION_KEY, and FORWARD_AUTH_SESSION_SECRET; fail-fast validation at startup when features are enabled - Escape all user-controlled template interpolations in OIDC login page (clientName, state, authCode, errorMessage) to prevent XSS - Replace URL query param secret display with one-time flash tokens (in-memory Map with 60s TTL, consumed on first access) - Update nodemailer from v6 to v7 (SES transport security fix) - Update docker-compose.yaml to use required variable syntax - Update .env.example with all current configuration options - Bump version to 2.0.2 --- CHANGELOG.md | 11 +++ gateway/.env.example | 51 +++++++++-- gateway/docker-compose.yaml | 10 +- gateway/package-lock.json | 12 +-- gateway/package.json | 4 +- gateway/src/index.ts | 29 ++++-- gateway/src/routes/admin-dashboard.test.ts | 102 +++++++++++++++++++-- gateway/src/routes/admin-dashboard.ts | 33 ++++++- gateway/src/routes/oidc/authorize.ts | 19 +++- 9 files changed, 231 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2478b6..7ba2fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.2] - 2026-02-23 + +### Security + +- Removed hardcoded fallback secrets from config; OIDC_KEY_SECRET, MFA_ENCRYPTION_KEY, and FORWARD_AUTH_SESSION_SECRET are now required when their features are enabled +- Fixed XSS in OIDC login page: all user-controlled values (clientName, state, authCode, errorMessage) are now HTML-escaped +- Client secrets no longer appear in URL query parameters; uses one-time flash tokens instead +- Updated nodemailer from v6 to v7 (SES transport security fix) +- Updated docker-compose.yaml to use required variable syntax (`${VAR:?msg}`) for secrets +- Updated .env.example with all current configuration options + ## [2.0.1] - 2026-02-23 ### Fixed diff --git a/gateway/.env.example b/gateway/.env.example index b1d3b2e..07c9b02 100644 --- a/gateway/.env.example +++ b/gateway/.env.example @@ -1,19 +1,56 @@ -# Gateway Server Configuration +# ATAuth Gateway Configuration +# Copy to .env and fill in required values + +# Server PORT=3100 HOST=0.0.0.0 -# OAuth Client Configuration -# Update these to match your deployment domain +# OAuth Client Configuration (AT Protocol) OAUTH_CLIENT_ID=https://auth.yourdomain.com/client-metadata.json OAUTH_REDIRECT_URI=https://auth.yourdomain.com/auth/callback -# Admin API Token (generate a secure random string) -# Required to register new applications via /admin/games -ADMIN_TOKEN=your-secure-admin-token-here +# Admin API Token (generate with: openssl rand -hex 32) +ADMIN_TOKEN= # Database Path (SQLite) DB_PATH=./data/gateway.db # CORS Origins (comma-separated) -# Add your application domains CORS_ORIGINS=http://localhost:3000,https://app.yourdomain.com + +# --- OIDC Provider --- +OIDC_ENABLED=true +OIDC_ISSUER=https://auth.yourdomain.com +# REQUIRED when OIDC is enabled (generate with: openssl rand -hex 32) +OIDC_KEY_SECRET= +OIDC_KEY_ALGORITHM=ES256 + +# --- Passkey/WebAuthn --- +PASSKEY_ENABLED=true +WEBAUTHN_RP_NAME=ATAuth +WEBAUTHN_RP_ID=auth.yourdomain.com +WEBAUTHN_ORIGIN=https://auth.yourdomain.com + +# --- MFA/TOTP --- +# MFA is enabled by default; set MFA_ENABLED=false to disable +MFA_TOTP_ISSUER=ATAuth +# REQUIRED when MFA is enabled - 64 hex chars (generate with: openssl rand -hex 32) +MFA_ENCRYPTION_KEY= +MFA_BACKUP_CODES_COUNT=10 + +# --- Email Verification --- +EMAIL_ENABLED=false +# EMAIL_PROVIDER=smtp +# EMAIL_FROM=ATAuth +# SMTP_HOST=smtp.yourdomain.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER= +# SMTP_PASS= + +# --- Forward-Auth Proxy --- +FORWARD_AUTH_ENABLED=false +# REQUIRED when forward-auth is enabled (generate with: openssl rand -hex 32) +# FORWARD_AUTH_SESSION_SECRET= +# FORWARD_AUTH_SESSION_TTL=604800 +# FORWARD_AUTH_PROXY_COOKIE_TTL=86400 diff --git a/gateway/docker-compose.yaml b/gateway/docker-compose.yaml index a9d0d9c..5c42875 100644 --- a/gateway/docker-compose.yaml +++ b/gateway/docker-compose.yaml @@ -25,13 +25,14 @@ services: # CORS - CORS_ORIGINS=https://example.com,http://localhost:3000 - # Admin token (generate with: openssl rand -hex 32) - - ADMIN_TOKEN=${ADMIN_TOKEN:-changeme} + # Admin token (REQUIRED - generate with: openssl rand -hex 32) + - ADMIN_TOKEN=${ADMIN_TOKEN:?Set ADMIN_TOKEN} # OIDC configuration - OIDC_ENABLED=true - OIDC_ISSUER=https://auth.example.com - - OIDC_KEY_SECRET=${OIDC_KEY_SECRET:-change-me-in-production-32-bytes!} + # REQUIRED when OIDC_ENABLED=true (generate with: openssl rand -hex 32) + - OIDC_KEY_SECRET=${OIDC_KEY_SECRET:?Set OIDC_KEY_SECRET} - OIDC_KEY_ALGORITHM=ES256 # Passkey/WebAuthn @@ -43,7 +44,8 @@ services: # MFA - MFA_ENABLED=true - MFA_TOTP_ISSUER=ATAuth - - MFA_ENCRYPTION_KEY=${MFA_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} + # REQUIRED when MFA is enabled (generate with: openssl rand -hex 64) + - MFA_ENCRYPTION_KEY=${MFA_ENCRYPTION_KEY:?Set MFA_ENCRYPTION_KEY} # Email (disabled by default) - EMAIL_ENABLED=false diff --git a/gateway/package-lock.json b/gateway/package-lock.json index 0d0b5e6..99f01f3 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -1,12 +1,12 @@ { "name": "atauth-gateway", - "version": "1.3.0", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "atauth-gateway", - "version": "1.3.0", + "version": "2.0.2", "license": "MIT", "dependencies": { "@atproto/oauth-client-node": "^0.3.0", @@ -15,7 +15,7 @@ "cors": "^2.8.5", "express": "^5.0.0", "helmet": "^8.0.0", - "nodemailer": "^6.9.9", + "nodemailer": "^7.0.13", "otpauth": "^9.2.1", "qrcode": "^1.5.3", "uuid": "^9.0.0" @@ -3650,9 +3650,9 @@ } }, "node_modules/nodemailer": { - "version": "6.9.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", - "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/gateway/package.json b/gateway/package.json index a7363e6..093cfdf 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -1,6 +1,6 @@ { "name": "atauth-gateway", - "version": "2.0.1", + "version": "2.0.2", "description": "AT Protocol OAuth gateway for application authentication", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -37,7 +37,7 @@ "cors": "^2.8.5", "express": "^5.0.0", "helmet": "^8.0.0", - "nodemailer": "^6.9.9", + "nodemailer": "^7.0.13", "otpauth": "^9.2.1", "qrcode": "^1.5.3", "uuid": "^9.0.0" diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 8cd17a0..841fdc2 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -53,7 +53,7 @@ const config = { oidc: { enabled: process.env.OIDC_ENABLED === 'true', issuer: process.env.OIDC_ISSUER || 'https://auth.example.com', - keySecret: process.env.OIDC_KEY_SECRET || 'change-me-in-production-32-bytes!', + keySecret: process.env.OIDC_KEY_SECRET, keyAlgorithm: (process.env.OIDC_KEY_ALGORITHM || 'ES256') as 'ES256' | 'RS256', }, @@ -70,7 +70,7 @@ const config = { enabled: process.env.MFA_ENABLED !== 'false', // Enabled by default issuer: process.env.MFA_TOTP_ISSUER || 'ATAuth', // 32-byte hex encryption key for TOTP secrets (64 hex characters) - encryptionKey: process.env.MFA_ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + encryptionKey: process.env.MFA_ENCRYPTION_KEY, backupCodesCount: parseInt(process.env.MFA_BACKUP_CODES_COUNT || '10', 10), }, @@ -93,7 +93,7 @@ const config = { // Forward-auth proxy configuration forwardAuth: { enabled: process.env.FORWARD_AUTH_ENABLED === 'true', - sessionSecret: process.env.FORWARD_AUTH_SESSION_SECRET || 'change-me-in-production-32-bytes!', + sessionSecret: process.env.FORWARD_AUTH_SESSION_SECRET, sessionTtl: parseInt(process.env.FORWARD_AUTH_SESSION_TTL || '604800', 10), // 7 days proxyCookieTtl: parseInt(process.env.FORWARD_AUTH_PROXY_COOKIE_TTL || '86400', 10), // 24h }, @@ -102,6 +102,23 @@ const config = { async function main(): Promise { console.log('Starting ATAuth Gateway...'); + // Validate required secrets when features are enabled + const missing: string[] = []; + if (config.oidc.enabled && !config.oidc.keySecret) { + missing.push('OIDC_KEY_SECRET is required when OIDC_ENABLED=true'); + } + if (config.mfa.enabled && !config.mfa.encryptionKey) { + missing.push('MFA_ENCRYPTION_KEY is required when MFA is enabled (set MFA_ENABLED=false to disable)'); + } + if (config.forwardAuth.enabled && !config.forwardAuth.sessionSecret) { + missing.push('FORWARD_AUTH_SESSION_SECRET is required when FORWARD_AUTH_ENABLED=true'); + } + if (missing.length > 0) { + console.error('Configuration error:'); + for (const msg of missing) console.error(` - ${msg}`); + process.exit(1); + } + // Ensure data directory exists const dataDir = path.dirname(config.dbPath); if (!fs.existsSync(dataDir)) { @@ -133,7 +150,7 @@ async function main(): Promise { if (config.oidc.enabled) { oidcService = new OIDCService(db, { issuer: config.oidc.issuer, - keySecret: config.oidc.keySecret, + keySecret: config.oidc.keySecret!, keyAlgorithm: config.oidc.keyAlgorithm, }); try { @@ -160,7 +177,7 @@ async function main(): Promise { if (config.mfa.enabled) { mfaService = new MFAService(db, { issuer: config.mfa.issuer, - encryptionKey: config.mfa.encryptionKey, + encryptionKey: config.mfa.encryptionKey!, backupCodesCount: config.mfa.backupCodesCount, }); console.log('MFA service initialized'); @@ -230,7 +247,7 @@ async function main(): Promise { // /auth/verify is called by nginx auth_request on every subrequest from a single // pod IP, so per-IP rate limiting would block legitimate traffic. if (config.forwardAuth.enabled) { - const proxyRouter = createProxyAuthRoutes(db, oauth, config.forwardAuth, config.oidc.issuer); + const proxyRouter = createProxyAuthRoutes(db, oauth, { ...config.forwardAuth, sessionSecret: config.forwardAuth.sessionSecret! }, config.oidc.issuer); // Mount entire proxy router at /auth -- no rate limit on /auth/verify app.use('/auth', proxyRouter); console.log('Forward-auth proxy enabled'); diff --git a/gateway/src/routes/admin-dashboard.test.ts b/gateway/src/routes/admin-dashboard.test.ts index 00dd48d..ae10e74 100644 --- a/gateway/src/routes/admin-dashboard.test.ts +++ b/gateway/src/routes/admin-dashboard.test.ts @@ -482,7 +482,8 @@ describe('Dashboard OIDC Clients', () => { expect(res.status).toBe(302); expect(res.headers.location).toContain('/admin/dashboard/clients/created'); expect(res.headers.location).toContain('id=myapp'); - expect(res.headers.location).toContain('secret='); + expect(res.headers.location).toContain('flash='); + expect(res.headers.location).not.toContain('secret='); // Verify in DB const client = db.getOIDCClient('myapp'); @@ -492,18 +493,80 @@ describe('Dashboard OIDC Clients', () => { expect(client!.require_pkce).toBe(true); }); - it('should display secret on created page', async () => { + it('should display secret on created page via flash', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/clients/new'); + + // Create a client to get a flash-based redirect + const createRes = await request(app) + .post('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + id: 'flash-test', + name: 'Flash Test', + redirect_uris_text: 'https://flash.example.com/callback', + grant_types: ['authorization_code'], + scopes: ['openid'], + auth_method: 'client_secret_basic', + require_pkce: 'on', + id_token_ttl: '3600', + access_token_ttl: '3600', + refresh_token_ttl: '604800', + }); + + expect(createRes.status).toBe(302); + const redirectUrl = createRes.headers.location as string; + + // Follow the redirect to display the secret + const queryString = redirectUrl.split('?')[1]; const res = await request(app) - .get('/admin/dashboard/clients/created?id=test&secret=abc123hex') + .get(`/admin/dashboard/clients/created?${queryString}`) .set('Cookie', adminCookie()); expect(res.status).toBe(200); expect(res.text).toContain('Client Created'); - expect(res.text).toContain('abc123hex'); expect(res.text).toContain('This secret will not be shown again'); expect(res.text).toContain('Discovery URL'); }); + it('should reject reused flash ID', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/clients/new'); + + const createRes = await request(app) + .post('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + id: 'flash-reuse-test', + name: 'Flash Reuse Test', + redirect_uris_text: 'https://reuse.example.com/callback', + grant_types: ['authorization_code'], + scopes: ['openid'], + auth_method: 'client_secret_basic', + id_token_ttl: '3600', + access_token_ttl: '3600', + refresh_token_ttl: '604800', + }); + + const redirectUrl = createRes.headers.location as string; + const queryString = redirectUrl.split('?')[1]; + + // First access should work + const res1 = await request(app) + .get(`/admin/dashboard/clients/created?${queryString}`) + .set('Cookie', adminCookie()); + expect(res1.status).toBe(200); + + // Second access should redirect (flash consumed) + const res2 = await request(app) + .get(`/admin/dashboard/clients/created?${queryString}`) + .set('Cookie', adminCookie()); + expect(res2.status).toBe(302); + expect(res2.headers.location).toBe('/admin/dashboard/clients'); + }); + it('should reject duplicate client ID', async () => { createTestOIDCClient(db); const csrf = await getCsrf(app, '/admin/dashboard/clients/new'); @@ -608,7 +671,8 @@ describe('Dashboard OIDC Clients', () => { expect(res.status).toBe(302); expect(res.headers.location).toContain('/admin/dashboard/clients/created'); expect(res.headers.location).toContain('rotated=1'); - expect(res.headers.location).toContain('secret='); + expect(res.headers.location).toContain('flash='); + expect(res.headers.location).not.toContain('secret='); // Verify secret changed const newClient = db.getOIDCClient('test-app'); @@ -749,8 +813,34 @@ describe('Dashboard Setup Wizard', () => { }); it('should show setup notes on created page', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/clients/wizard/audiobookshelf'); + + // Create via wizard to get flash-based redirect with preset + const createRes = await request(app) + .post('/admin/dashboard/clients/wizard/audiobookshelf') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + preset: 'audiobookshelf', + domain: 'abs-notes.example.com', + id: 'abs-notes', + name: 'ABS Notes Test', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + auth_method: 'client_secret_basic', + require_pkce: 'on', + id_token_ttl: '3600', + access_token_ttl: '3600', + refresh_token_ttl: '604800', + }); + + expect(createRes.status).toBe(302); + const redirectUrl = createRes.headers.location as string; + const queryString = redirectUrl.split('?')[1]; + const res = await request(app) - .get('/admin/dashboard/clients/created?id=abs&secret=abc123&preset=audiobookshelf') + .get(`/admin/dashboard/clients/created?${queryString}`) .set('Cookie', adminCookie()); expect(res.status).toBe(200); diff --git a/gateway/src/routes/admin-dashboard.ts b/gateway/src/routes/admin-dashboard.ts index 719daf1..5bf257a 100644 --- a/gateway/src/routes/admin-dashboard.ts +++ b/gateway/src/routes/admin-dashboard.ts @@ -24,6 +24,27 @@ export function createAdminDashboardRoutes( ): Router { const router = Router(); + // Short-lived store for one-time secret display (avoids secret in URL) + const secretFlash = new Map(); + + function storeFlash(secret: string): string { + const flashId = crypto.randomBytes(16).toString('hex'); + secretFlash.set(flashId, { secret, expires: Date.now() + 60_000 }); + // Sweep expired entries + for (const [k, v] of secretFlash) { + if (v.expires < Date.now()) secretFlash.delete(k); + } + return flashId; + } + + function consumeFlash(flashId: string): string | null { + const entry = secretFlash.get(flashId); + if (!entry) return null; + secretFlash.delete(flashId); + if (entry.expires < Date.now()) return null; + return entry.secret; + } + // ===== CSRF Protection ===== function generateCsrfToken(): string { @@ -625,7 +646,8 @@ export function createAdminDashboardRoutes( refresh_token_ttl_seconds: parseInt(refresh_token_ttl, 10) || 604800, }); - const params = new URLSearchParams({ id, secret: clientSecret }); + const flashId = storeFlash(clientSecret); + const params = new URLSearchParams({ id, flash: flashId }); if (preset) params.set('preset', preset); res.redirect(`/admin/dashboard/clients/created?${params.toString()}`); }); @@ -633,7 +655,8 @@ export function createAdminDashboardRoutes( // --- Secret Display (one-time) --- router.get('/clients/created', (req: Request, res: Response) => { - const { id, secret, preset, rotated } = req.query as Record; + const { id, flash, preset, rotated } = req.query as Record; + const secret = flash ? consumeFlash(flash) : null; if (!id || !secret) { return res.redirect('/admin/dashboard/clients'); } @@ -825,7 +848,8 @@ export function createAdminDashboardRoutes( const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex'); db.updateOIDCClientSecret(clientId, clientSecretHash); - const params = new URLSearchParams({ id: clientId, secret: clientSecret, rotated: '1' }); + const flashId = storeFlash(clientSecret); + const params = new URLSearchParams({ id: clientId, flash: flashId, rotated: '1' }); res.redirect(`/admin/dashboard/clients/created?${params.toString()}`); }); @@ -1007,7 +1031,8 @@ export function createAdminDashboardRoutes( refresh_token_ttl_seconds: parseInt(refresh_token_ttl, 10) || presetData.refresh_token_ttl_seconds, }); - const params = new URLSearchParams({ id, secret: clientSecret, preset: preset || req.params.preset }); + const flashId = storeFlash(clientSecret); + const params = new URLSearchParams({ id, flash: flashId, preset: preset || req.params.preset }); res.redirect(`/admin/dashboard/clients/created?${params.toString()}`); }); diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index a85ddd2..1edf1e4 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -19,9 +19,18 @@ export function createAuthorizeRouter( ): Router { const router = Router(); + function esc(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + function renderLoginPage(clientName: string, authCode: string, state: string, errorMessage?: string, nonce?: string): string { const errorHtml = errorMessage - ? `
${errorMessage}
` + ? `
${esc(errorMessage)}
` : '
'; return ` @@ -105,17 +114,17 @@ export function createAuthorizeRouter(

Sign in with ATAuth

Use your Bluesky or AT Protocol identity

-
Signing in to ${clientName}
+
Signing in to ${esc(clientName)}
${errorHtml}
- - + +

Enter your Bluesky handle or custom domain

-

Bluesky will ask to authorize broad access — this is a limitation of the AT Protocol OAuth standard. ${clientName} only reads your identity (handle). It will never post, follow, or access your data.

+

Bluesky will ask to authorize broad access — this is a limitation of the AT Protocol OAuth standard. ${esc(clientName)} only reads your identity (handle). It will never post, follow, or access your data.

var form = document.getElementById('loginForm'); -- knotmirror 0.0.1