← back to AbramsOS
middleware/basic-auth.js
84 lines
// App-level HTTP Basic Auth — defense-in-depth gate for AbramsOS.
//
// The public door is ALREADY guarded by Kamatera nginx (auth_basic, admin/DW2024!),
// and nginx forwards the Authorization header to this app on proxy_pass. This middleware
// re-validates the SAME credentials at the app layer so the personal-claims data can NEVER
// be reached even if the nginx Basic Auth is ever removed/misconfigured, or the app port
// (:9774) is hit directly over the tailnet. Because nginx forwards the header, a real user
// behind nginx is NOT prompted twice — their already-supplied creds satisfy this gate silently.
//
// Credentials: env BASIC_AUTH ("user:pass"), else BASIC_AUTH_USER/BASIC_AUTH_PASS,
// else the fleet default admin/DW2024!. Enforced whenever APP_BASIC_AUTH is truthy
// (default ON in production). /healthz and the ACME challenge path stay exempt so
// uptime probes and certbot renewals keep working.
const crypto = require('crypto');
function parseCreds() {
const combo = process.env.BASIC_AUTH;
if (combo && combo.includes(':')) {
const i = combo.indexOf(':');
return { user: combo.slice(0, i), pass: combo.slice(i + 1) };
}
return {
user: process.env.BASIC_AUTH_USER || 'admin',
pass: process.env.BASIC_AUTH_PASS || 'DW2024!',
};
}
const CREDS = parseCreds();
// Timing-safe string compare (avoids length/early-exit leaks).
function safeEqual(a, b) {
const ab = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ab.length !== bb.length) {
// still spend time comparing to keep it constant-ish
crypto.timingSafeEqual(ab, ab);
return false;
}
return crypto.timingSafeEqual(ab, bb);
}
const EXEMPT = [
(p) => p === '/healthz',
(p) => p.startsWith('/.well-known/acme-challenge/'),
];
function basicAuth(req, res, next) {
// Enabled by default in production; opt-in elsewhere via APP_BASIC_AUTH=1.
const enabled =
process.env.APP_BASIC_AUTH !== undefined
? /^(1|true|on|yes)$/i.test(process.env.APP_BASIC_AUTH)
: process.env.NODE_ENV === 'production';
if (!enabled) return next();
const p = req.path || req.url || '';
if (EXEMPT.some((fn) => fn(p))) return next();
const header = req.headers.authorization || '';
const m = /^Basic\s+(.+)$/i.exec(header);
if (m) {
let decoded = '';
try {
decoded = Buffer.from(m[1], 'base64').toString('utf8');
} catch (_e) {
decoded = '';
}
const i = decoded.indexOf(':');
if (i !== -1) {
const user = decoded.slice(0, i);
const pass = decoded.slice(i + 1);
// Evaluate both to keep timing uniform.
const okUser = safeEqual(user, CREDS.user);
const okPass = safeEqual(pass, CREDS.pass);
if (okUser && okPass) return next();
}
}
res.set('WWW-Authenticate', 'Basic realm="AbramsOS", charset="UTF-8"');
return res.status(401).type('text/plain').send('Authentication required.');
}
module.exports = basicAuth;