The server had no authentication: every /api route was open, CORS allowed any origin, and the identity written to the audit log came from the request body — the UI sent a hardcoded pf_user: 'admin', which any client could have set to anything it liked. Accounts live in pf.app_user with scrypt hashes from node's own crypto, so there is no native build step and the parameters travel with each hash. Sessions are express-session over connect-pg-simple in pf.session: a restart no longer signs everyone out, and a session can be revoked by deleting its row, which is how disable-user cuts off access immediately rather than at cookie expiry. Everything under /api except login/logout/me now requires a session, and the React app is mounted only once there is one — its load effects call the API on mount, so a logged-out mount would just fire a burst of 401s. A session that expires while the app is open lands back on the login screen: auth.jsx wraps fetch once rather than teaching every call site to check. Identity is now read from the session for pf_user, created_by and closed_by, and the body values are ignored. Hardened for an internet-facing deployment: trust proxy so req.ip and secure-cookie detection are right behind TLS termination, httpOnly + SameSite=Lax + Secure cookies, ten login failures per IP per fifteen minutes, one error message for unknown, wrong and disabled alike, and a fresh session id on success. CORS is off entirely unless CORS_ORIGIN names an origin — a wildcard alongside a session cookie would be CSRF by construction. The server refuses to boot without SESSION_SECRET rather than falling back to a guessable default. pf.sh grows add-user, passwd, list-users, disable-user and enable-user; passwords are read on stdin and hashed before they reach psql, so no plaintext in argv or shell history. install.sh generates the secret, applies 02_auth.sql, and creates the first account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87 lines
3.1 KiB
JavaScript
87 lines
3.1 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const session = require('express-session');
|
|
const PgSession = require('connect-pg-simple')(session);
|
|
const { Pool, types } = require('pg');
|
|
const { requireAuth } = require('./lib/auth');
|
|
|
|
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
|
|
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
|
|
types.setTypeParser(20, v => v === null ? null : Number(v));
|
|
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
|
|
|
const app = express();
|
|
|
|
// Sessions ride on a cookie, so a wildcard CORS origin would let any site make
|
|
// credentialed calls on behalf of a logged-in user. The UI is served from this
|
|
// same origin and needs no CORS at all; set CORS_ORIGIN only for a separate
|
|
// front-end host, and it is then allowed by name, never by wildcard.
|
|
if (process.env.CORS_ORIGIN) {
|
|
app.use(cors({ origin: process.env.CORS_ORIGIN.split(',').map(o => o.trim()), credentials: true }));
|
|
}
|
|
|
|
app.use(express.json());
|
|
app.use(express.static('public/app'));
|
|
|
|
const pool = new Pool({
|
|
host: process.env.DB_HOST,
|
|
port: parseInt(process.env.DB_PORT) || 5432,
|
|
database: process.env.DB_NAME,
|
|
user: process.env.DB_USER,
|
|
password: process.env.DB_PASSWORD,
|
|
ssl: false
|
|
});
|
|
|
|
pool.on('error', (err) => {
|
|
console.error('pg pool error', err);
|
|
});
|
|
|
|
// ── Authentication ────────────────────────────────────────────
|
|
// Refuse to boot without a secret rather than fall back to a default one:
|
|
// a predictable secret means forgeable session cookies.
|
|
const sessionSecret = process.env.SESSION_SECRET;
|
|
if (!sessionSecret) {
|
|
console.error('SESSION_SECRET is not set. Run: ./pf.sh config');
|
|
process.exit(1);
|
|
}
|
|
|
|
// TLS terminates at the reverse proxy, so express has to trust its headers for
|
|
// req.ip (the login throttle) and for secure-cookie detection to be right.
|
|
app.set('trust proxy', process.env.TRUST_PROXY || 1);
|
|
|
|
const cookieSecure = process.env.COOKIE_SECURE !== 'false';
|
|
if (!cookieSecure) {
|
|
console.warn('COOKIE_SECURE=false — session cookie will be sent over plain HTTP.');
|
|
}
|
|
|
|
app.use(session({
|
|
name: 'pf.sid',
|
|
store: new PgSession({ pool, schemaName: 'pf', tableName: 'session', createTableIfMissing: false }),
|
|
secret: sessionSecret,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
rolling: true,
|
|
cookie: {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: cookieSecure,
|
|
maxAge: 1000 * 60 * 60 * 12,
|
|
},
|
|
}));
|
|
|
|
app.use('/api', require('./routes/auth')(pool));
|
|
|
|
// Everything below this line requires a session.
|
|
app.use('/api', requireAuth);
|
|
|
|
app.use('/api', require('./routes/tables')(pool));
|
|
app.use('/api', require('./routes/sources')(pool));
|
|
app.use('/api', require('./routes/versions')(pool));
|
|
app.use('/api', require('./routes/operations')(pool));
|
|
app.use('/api', require('./routes/log')(pool));
|
|
|
|
|
|
const port = process.env.PORT || 3010;
|
|
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));
|