← 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;