Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
89 changes: 82 additions & 7 deletions lib/appFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,77 @@ 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');
Comment thread
sidhujag marked this conversation as resolved.
}
}

// 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
// `server.js` alongside its unaffected public routes.
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 }),
Expand All @@ -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 +
Expand Down Expand Up @@ -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 ||
Expand All @@ -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,
Comment thread
sidhujag marked this conversation as resolved.
});
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);
Expand Down Expand Up @@ -429,8 +502,10 @@ function createApp({
}

module.exports = {
assertProductionAuthConfig,
buildServices,
finalizeSessionMw,
mountAuthAndVault,
normalizeProductionCorsOrigin,
createApp,
};
14 changes: 13 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
160 changes: 160 additions & 0 deletions tests/appFactory.config.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
Loading