diff --git a/README.md b/README.md index 4c248a9..56915dd 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,31 @@ All configuration is via environment variables. `.env.example` is the source of | `SYSCOIN_RPC_USER`, `SYSCOIN_RPC_PASS` | Fallback static creds for remote RPC nodes | | `SYSCOIN_NETWORK`, `SYSCOIN_BLOCKBOOK_URL` | Enables the Pay-with-Pali collateral PSBT path | +## Production authenticated deployment + +Real voting-key custody should use a same-origin deployment for the authenticated API surface. Serve the SPA and proxy `/auth`, `/vault`, and `/gov` from the same public HTTPS origin: + +```text +https://sysnode.info/ -> sysnode-info build +https://sysnode.info/auth/* -> sysnode-backend +https://sysnode.info/vault/* -> sysnode-backend +https://sysnode.info/gov/* -> sysnode-backend +``` + +This keeps the `sid` and `csrf` cookies host-only with `Secure; SameSite=Lax`, and lets the SPA read the `csrf` cookie from the same host before mirroring it into `X-CSRF-Token`. Do not deploy the real-key auth/vault/voting surface as `sysnode.info` plus a cross-site API host. + +For production, set at least: + +```bash +NODE_ENV=production +FRONTEND_URL=https://sysnode.info +CORS_ORIGIN=https://sysnode.info +TRUST_PROXY=1 # or the exact trusted proxy/CIDR for your edge +SYSNODE_AUTH_PEPPER=<32-byte-hex-secret> +``` + +Production startup refuses non-secure cookies, non-HTTPS `FRONTEND_URL`, or a credentialed CORS origin that differs from `FRONTEND_URL`. + ### Cookie vs static RPC auth The backend supports both authentication modes and picks **cookie over static** when both are configured (with a one-line warning at boot). Cookie auth is zero-secret-management: `syscoind` rewrites the cookie on every restart, and the backend picks up the new token automatically via a 401-driven replay. Use it for any deployment where the backend runs on the same host as `syscoind`. diff --git a/lib/appFactory.js b/lib/appFactory.js index 70a2d66..6cfa923 100644 --- a/lib/appFactory.js +++ b/lib/appFactory.js @@ -23,6 +23,65 @@ const { createGovRouter } = require('../routes/gov'); const { createGovProposalsRouter } = require('../routes/govProposals'); const securityLog = require('./securityLog'); +function parseBooleanEnv(value, name) { + if (value === undefined || value === '') return undefined; + if (value === 'true') return true; + if (value === 'false') return false; + throw new Error(`${name}_invalid`); +} + +function isProduction() { + return process.env.NODE_ENV === 'production'; +} + +function resolveSecureCookies(explicit) { + if (typeof explicit === 'boolean') return explicit; + const fromEnv = parseBooleanEnv( + process.env.SYSNODE_SECURE_COOKIES, + 'SYSNODE_SECURE_COOKIES' + ); + if (typeof fromEnv === 'boolean') return fromEnv; + return isProduction(); +} + +function assertSecureCookieConfig(secureCookies) { + if (isProduction() && secureCookies !== true) { + throw new Error('secure_cookies_required_in_production'); + } +} + +function normalizeHttpsOrigin(value) { + try { + const url = new URL(value); + if (url.protocol !== 'https:') return null; + return url.origin; + } catch (_err) { + return null; + } +} + +function normalizeProductionCorsOrigin(corsOrigin) { + try { + return new URL(corsOrigin).origin; + } catch (_err) { + return corsOrigin; + } +} + +function assertProductionAuthConfig({ secureCookies, corsOrigin, frontendUrl }) { + assertSecureCookieConfig(secureCookies); + if (!isProduction()) return; + + const frontendOrigin = normalizeHttpsOrigin(frontendUrl); + const corsNormalizedOrigin = normalizeHttpsOrigin(corsOrigin); + if (!frontendOrigin) { + throw new Error('frontend_https_url_required_in_production'); + } + if (!corsNormalizedOrigin || corsNormalizedOrigin !== frontendOrigin) { + throw new Error('same_origin_cors_required_in_production'); + } +} + // Build the stateful services (repos + middlewares) around a DB handle. // Pure-ish: no Express side effects yet, so the same object graph can be // mounted onto either a dedicated test app (see `createApp`) or the legacy @@ -30,9 +89,11 @@ const securityLog = require('./securityLog'); function buildServices({ db, now = () => Date.now(), - secureCookies = process.env.NODE_ENV === 'production', + secureCookies, } = {}) { if (!db) throw new Error('buildServices: db is required'); + const resolvedSecureCookies = resolveSecureCookies(secureCookies); + assertSecureCookieConfig(resolvedSecureCookies); return { users: createUsersRepo(db, { now }), sessions: createSessionStore(db, { now }), @@ -43,8 +104,8 @@ function buildServices({ proposalDrafts: createProposalDraftsRepo(db, { now }), proposalSubmissions: createProposalSubmissionsRepo(db, { now }), sessionMw: null, // finalized once we know `users` - csrfMw: createCsrfMiddleware({ secureCookies }), - secureCookies, + csrfMw: createCsrfMiddleware({ secureCookies: resolvedSecureCookies }), + secureCookies: resolvedSecureCookies, // Shared atomic-write helper. Route handlers that touch multiple // repos (/verify-email = pendingRegistrations.redeem + // users.create; /change-password = users.updateAuthHash + @@ -243,8 +304,10 @@ function createApp({ db, mailer, now = () => Date.now(), - secureCookies = process.env.NODE_ENV === 'production', - corsOrigin = process.env.CORS_ORIGIN || 'http://localhost:3000', + secureCookies, + corsOrigin = process.env.CORS_ORIGIN || + process.env.FRONTEND_URL || + 'http://localhost:3000', baseUrl = process.env.BASE_URL || 'http://localhost:3001', frontendUrl = process.env.FRONTEND_URL || process.env.CORS_ORIGIN || @@ -271,11 +334,21 @@ function createApp({ if (!db) throw new Error('appFactory: db is required'); if (!mailer) throw new Error('appFactory: mailer is required'); - const services = finalizeSessionMw(buildServices({ db, now, secureCookies })); + const resolvedSecureCookies = resolveSecureCookies(secureCookies); + assertProductionAuthConfig({ + secureCookies: resolvedSecureCookies, + corsOrigin, + frontendUrl, + }); + const effectiveCorsOrigin = normalizeProductionCorsOrigin(corsOrigin); + + const services = finalizeSessionMw( + buildServices({ db, now, secureCookies: resolvedSecureCookies }) + ); const app = express(); app.use(helmet()); - app.use(cors({ origin: corsOrigin, credentials: true })); + app.use(cors({ origin: effectiveCorsOrigin, credentials: true })); app.use(express.json({ limit: '256kb' })); app.use(cookieParser()); app.use(services.sessionMw.parse); @@ -429,8 +502,10 @@ function createApp({ } module.exports = { + assertProductionAuthConfig, buildServices, finalizeSessionMw, mountAuthAndVault, + normalizeProductionCorsOrigin, createApp, }; diff --git a/server.js b/server.js index 53d5f69..e54aaa3 100644 --- a/server.js +++ b/server.js @@ -23,9 +23,11 @@ const { createMailer } = require('./lib/mailer'); const { selectMailTransport } = require('./lib/mailTransport'); const { assertPepperConfigured } = require('./lib/kdf'); const { + assertProductionAuthConfig, buildServices, finalizeSessionMw, mountAuthAndVault, + normalizeProductionCorsOrigin, } = require('./lib/appFactory'); const dataStore = require('./data/dataStore'); const { client, rpcServices } = require('./services/rpcClient'); @@ -105,8 +107,13 @@ app.use(cookieParser()); // legacy surface evolves. // ----------------------------------------------------------------------------- const legacyCors = cors({ origin: '*', optionsSuccessStatus: 200 }); +const AUTH_ORIGIN = normalizeProductionCorsOrigin( + process.env.CORS_ORIGIN || + process.env.FRONTEND_URL || + 'http://localhost:3000' +); const authCors = cors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + origin: AUTH_ORIGIN, credentials: true, }); const { isCredentialedPath } = require('./lib/credentialedPaths'); @@ -182,6 +189,11 @@ const mailer = createMailer({ publicBaseUrl: PUBLIC_BASE_URL, }); const services = finalizeSessionMw(buildServices({ db })); +assertProductionAuthConfig({ + secureCookies: services.secureCookies, + corsOrigin: AUTH_ORIGIN, + frontendUrl: PUBLIC_BASE_URL, +}); // Session parsing must cover every route that reads `req.user`. /gov // uses `requireAuth` in its router; without parse running here first diff --git a/tests/appFactory.config.test.js b/tests/appFactory.config.test.js new file mode 100644 index 0000000..d0cfba9 --- /dev/null +++ b/tests/appFactory.config.test.js @@ -0,0 +1,160 @@ +const { + assertProductionAuthConfig, + buildServices, + createApp, + normalizeProductionCorsOrigin, +} = require('../lib/appFactory'); +const { openDatabase } = require('../lib/db'); +const { createMailer } = require('../lib/mailer'); +const { _resetPepperForTests } = require('../lib/kdf'); +const request = require('supertest'); + +describe('appFactory production auth config', () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalSecureCookies = process.env.SYSNODE_SECURE_COOKIES; + const originalCorsOrigin = process.env.CORS_ORIGIN; + const originalFrontendUrl = process.env.FRONTEND_URL; + + beforeEach(() => { + _resetPepperForTests(); + process.env.SYSNODE_AUTH_PEPPER = 'f'.repeat(64); + delete process.env.SYSNODE_SECURE_COOKIES; + }); + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + if (originalSecureCookies === undefined) { + delete process.env.SYSNODE_SECURE_COOKIES; + } else { + process.env.SYSNODE_SECURE_COOKIES = originalSecureCookies; + } + if (originalCorsOrigin === undefined) { + delete process.env.CORS_ORIGIN; + } else { + process.env.CORS_ORIGIN = originalCorsOrigin; + } + if (originalFrontendUrl === undefined) { + delete process.env.FRONTEND_URL; + } else { + process.env.FRONTEND_URL = originalFrontendUrl; + } + }); + + test('production refuses non-secure cookies', () => { + process.env.NODE_ENV = 'production'; + expect(() => + assertProductionAuthConfig({ + secureCookies: false, + corsOrigin: 'https://sysnode.info', + frontendUrl: 'https://sysnode.info', + }) + ).toThrow('secure_cookies_required_in_production'); + }); + + test('production requires same frontend and credentialed CORS origin', () => { + process.env.NODE_ENV = 'production'; + expect(() => + assertProductionAuthConfig({ + secureCookies: true, + corsOrigin: 'https://api.sysnode.info', + frontendUrl: 'https://sysnode.info', + }) + ).toThrow('same_origin_cors_required_in_production'); + }); + + test('production accepts equivalent same-origin URLs after normalization', () => { + process.env.NODE_ENV = 'production'; + expect(() => + assertProductionAuthConfig({ + secureCookies: true, + corsOrigin: 'https://sysnode.info/', + frontendUrl: 'https://sysnode.info/path-that-is-not-used', + }) + ).not.toThrow(); + }); + + test('production requires an https frontend origin', () => { + process.env.NODE_ENV = 'production'; + expect(() => + assertProductionAuthConfig({ + secureCookies: true, + corsOrigin: 'http://sysnode.info', + frontendUrl: 'http://sysnode.info', + }) + ).toThrow('frontend_https_url_required_in_production'); + }); + + test('SYSNODE_SECURE_COOKIES=false is rejected in production', () => { + process.env.NODE_ENV = 'production'; + process.env.SYSNODE_SECURE_COOKIES = 'false'; + const db = openDatabase(':memory:'); + try { + expect(() => buildServices({ db })).toThrow( + 'secure_cookies_required_in_production' + ); + } finally { + db.close(); + } + }); + + test('explicit secureCookies option ignores malformed env override', () => { + process.env.NODE_ENV = 'test'; + process.env.SYSNODE_SECURE_COOKIES = '0'; + const db = openDatabase(':memory:'); + try { + expect(() => buildServices({ db, secureCookies: false })).not.toThrow(); + } finally { + db.close(); + } + }); + + test('standalone createApp accepts FRONTEND_URL as production CORS origin fallback', () => { + process.env.NODE_ENV = 'production'; + delete process.env.CORS_ORIGIN; + process.env.FRONTEND_URL = 'https://sysnode.info'; + const db = openDatabase(':memory:'); + const mailer = createMailer({ transport: 'memory', from: 't@x.com' }); + try { + expect(() => createApp({ db, mailer })).not.toThrow(); + } finally { + db.close(); + } + }); + + test('standalone createApp emits normalized production CORS origin', async () => { + process.env.NODE_ENV = 'production'; + const db = openDatabase(':memory:'); + const mailer = createMailer({ transport: 'memory', from: 't@x.com' }); + const { app } = createApp({ + db, + mailer, + corsOrigin: 'https://sysnode.info/', + frontendUrl: 'https://sysnode.info/path-that-is-not-used', + }); + try { + const res = await request(app) + .get('/health') + .set('Origin', 'https://sysnode.info'); + expect(res.headers['access-control-allow-origin']).toBe( + 'https://sysnode.info' + ); + } finally { + db.close(); + } + }); + + test('normalizes production CORS origins and preserves development values', () => { + process.env.NODE_ENV = 'production'; + expect(normalizeProductionCorsOrigin('https://sysnode.info/')).toBe( + 'https://sysnode.info' + ); + process.env.NODE_ENV = 'development'; + expect(normalizeProductionCorsOrigin('http://localhost:3000/app')).toBe( + 'http://localhost:3000' + ); + }); +});