← back to AbramsOS
abramsos: add app-level defense-in-depth Basic Auth (401 without creds, 200 with admin:DW2024!)
8aca6e83e6a484646bf815de9fd6a66dfb6dbdf1 · 2026-08-19 22:50:17 -0700 · Steve
Files touched
A middleware/basic-auth.jsM server.js
Diff
commit 8aca6e83e6a484646bf815de9fd6a66dfb6dbdf1
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Aug 19 22:50:17 2026 -0700
abramsos: add app-level defense-in-depth Basic Auth (401 without creds, 200 with admin:DW2024!)
---
middleware/basic-auth.js | 83 ++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 6 ++++
2 files changed, 89 insertions(+)
diff --git a/middleware/basic-auth.js b/middleware/basic-auth.js
new file mode 100644
index 0000000..3079e59
--- /dev/null
+++ b/middleware/basic-auth.js
@@ -0,0 +1,83 @@
+// 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;
diff --git a/server.js b/server.js
index ca8a5ba..64ba8e7 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@ const path = require('path');
const cookieParser = require('cookie-parser');
const { loadSessionMiddleware, requireAuth, requireStepUp } = require('./middleware/auth');
+const basicAuth = require('./middleware/basic-auth');
const csrf = require('./middleware/csrf');
const home = require('./routes/home');
@@ -44,6 +45,11 @@ app.set('views', path.join(__dirname, 'views'));
app.set('trust proxy', 1); // behind Kamatera nginx (sets X-Forwarded-Proto) — makes req.secure accurate over HTTPS
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
+// Defense-in-depth HTTP Basic Auth (app layer). The public door is guarded by Kamatera
+// nginx (admin/DW2024!) which forwards the Authorization header, so real users are not
+// prompted twice — but this ensures the personal-claims data is 401'd even on a direct
+// :9774 hit or if the nginx gate is ever removed. Exempts /healthz + ACME. See middleware/basic-auth.js.
+app.use(basicAuth);
app.use(morgan('tiny'));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
← fca604e abramsos: CNCP parking-lot fallback fires only when George u
·
back to AbramsOS
·
abramsos: skip-medical guard in mode-claims ingest so purged 3db1e3c →