Admin sits behind a 6-digit PIN
The passcode machinery existed but could only be set by hand in a config file, so in practice every install left admin open to anyone on the network. It is now a PIN you set from the admin page itself. - Six digits, stored as a scrypt hash in this machine's proddash.json — never in the clear, since that file gets copied between machines. - The unlock card takes the PIN and submits itself on the sixth digit. Five wrong tries from one address locks it out for 30 seconds, and the right PIN is refused during the lockout. - Changing or turning off the PIN asks for the current one, so a browser left open on the admin page cannot be used to take the machine over. Whoever sets it keeps their session rather than being bounced to the card they just built. - This is the shell's own PIN and guards /admin and the admin API only. Dashboards stay open, and it is unrelated to a module's PIN — Slack's 4-8 digit send PIN still guards sending, separately and independently. - Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment (which overrides the PIN), or delete adminPin from proddash.json and restart. Both are in the README and on the page itself. - The older free-text adminPasscode still works, so an existing install keeps running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
095fe66118
commit
6dabd9d8a1
@@ -12,7 +12,8 @@
|
||||
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||
* PORT (default: value in proddash.json, else 24500)
|
||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||
* PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
|
||||
* also the way back in if the PIN is ever forgotten)
|
||||
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||
*/
|
||||
'use strict';
|
||||
@@ -1130,12 +1131,109 @@ function restartServer(reason) {
|
||||
|
||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
|
||||
machine's proddash.json as a scrypt hash — never in the clear, because that
|
||||
file gets copied between machines. A dashboard needs no PIN; only /admin
|
||||
and the admin API do. Two ways back in if it is ever forgotten: set
|
||||
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
|
||||
proddash.json in the data directory and restart. `adminPasscode` (the older
|
||||
free-text setting) still works so an existing install keeps running. */
|
||||
const PIN_RE = /^\d{6}$/;
|
||||
const PIN_MAX_WRONG = 5;
|
||||
const PIN_LOCKOUT_MS = 30000;
|
||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
|
||||
/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
|
||||
const adminToken = crypto.randomBytes(24).toString('hex');
|
||||
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
|
||||
let adminPinHash = String(shellConfig.adminPin || '');
|
||||
const pinAttempts = new Map(); // ip → { count, lockedUntil }
|
||||
|
||||
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** Constant-time check of a PIN against the stored hash. */
|
||||
function pinMatches(given, stored) {
|
||||
const parts = String(stored || '').split('$');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||
const expected = Buffer.from(parts[2], 'hex');
|
||||
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
/** Is a credential required at all? */
|
||||
function adminLocked() {
|
||||
return Boolean(ADMIN_PASSCODE || adminPinHash);
|
||||
}
|
||||
|
||||
/** True when `given` is the credential in force (the env passcode, or the PIN). */
|
||||
function credentialOk(given) {
|
||||
const s = String(given ?? '');
|
||||
if (!s) return false;
|
||||
if (ADMIN_PASSCODE) {
|
||||
const a = Buffer.from(s);
|
||||
const b = Buffer.from(ADMIN_PASSCODE);
|
||||
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
|
||||
}
|
||||
return adminPinHash ? pinMatches(s, adminPinHash) : false;
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return String(req.socket?.remoteAddress || 'unknown');
|
||||
}
|
||||
|
||||
/** Seconds of lockout left for this address, or 0. */
|
||||
function lockoutLeft(ip) {
|
||||
const a = pinAttempts.get(ip);
|
||||
if (!a || !a.lockedUntil) return 0;
|
||||
const left = a.lockedUntil - Date.now();
|
||||
if (left <= 0) {
|
||||
pinAttempts.delete(ip);
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil(left / 1000);
|
||||
}
|
||||
|
||||
function noteWrongPin(ip) {
|
||||
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
|
||||
a.count += 1;
|
||||
if (a.count >= PIN_MAX_WRONG) {
|
||||
a.count = 0;
|
||||
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
|
||||
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
|
||||
}
|
||||
pinAttempts.set(ip, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
|
||||
* key in this machine's proddash.json. Throws an httpError the route reports.
|
||||
*/
|
||||
function setAdminPin(pin) {
|
||||
const next = String(pin ?? '');
|
||||
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
|
||||
const file = path.join(DATA_DIR, 'proddash.json');
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) {
|
||||
existing = readJson(file, null);
|
||||
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
||||
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
|
||||
}
|
||||
}
|
||||
const hash = next ? hashPin(next) : '';
|
||||
const out = { ...existing };
|
||||
if (hash) out.adminPin = hash;
|
||||
else delete out.adminPin;
|
||||
writeJson(file, out);
|
||||
adminPinHash = hash;
|
||||
shellConfig.adminPin = hash;
|
||||
pinAttempts.clear();
|
||||
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
|
||||
return { pinSet: Boolean(hash) };
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
if (!ADMIN_PASSCODE) return true;
|
||||
if (!adminLocked()) return true;
|
||||
const cookies = String(req.headers.cookie || '');
|
||||
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
|
||||
}
|
||||
@@ -1143,7 +1241,7 @@ function isAuthed(req) {
|
||||
/** Guard a state-changing admin route. Returns true when the request was refused. */
|
||||
function refuseAdminWrite(req, res) {
|
||||
if (!isAuthed(req)) {
|
||||
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||
return true;
|
||||
}
|
||||
// Requiring application/json forces a CORS preflight that a hostile page
|
||||
@@ -1228,10 +1326,19 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
|
||||
return sendJson(res, 403, { error: 'Wrong passcode.' });
|
||||
if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||
const ip = clientIp(req);
|
||||
const left = lockoutLeft(ip);
|
||||
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||
// `passcode` is what the older admin page sent; `pin` is what this one does.
|
||||
if (!credentialOk(body.pin ?? body.passcode)) {
|
||||
noteWrongPin(ip);
|
||||
const now = lockoutLeft(ip);
|
||||
return sendJson(res, 403, now
|
||||
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
|
||||
: { error: 'Wrong PIN.' });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
@@ -1240,12 +1347,50 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
// Changing a credential re-asserts it, so a browser left open on the
|
||||
// admin page can't be used to take the machine over.
|
||||
if (adminPinHash) {
|
||||
const ip = clientIp(req);
|
||||
const left = lockoutLeft(ip);
|
||||
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
|
||||
noteWrongPin(ip);
|
||||
return sendJson(res, 403, { error: 'That is not the current PIN.' });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
}
|
||||
try {
|
||||
const result = setAdminPin(body.pin);
|
||||
// Whoever just set it has proved they are allowed, so hand them the
|
||||
// session rather than bouncing them to the unlock card they just built.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
return res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (urlPath === '/api/admin/state' && req.method === 'GET') {
|
||||
if (!isAuthed(req)) {
|
||||
return sendJson(res, 401, { authRequired: true, authed: false });
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
authRequired: Boolean(ADMIN_PASSCODE),
|
||||
authRequired: adminLocked(),
|
||||
// The env passcode can't be changed from here; the page says so.
|
||||
pinSet: Boolean(adminPinHash),
|
||||
pinFromEnv: Boolean(ADMIN_PASSCODE),
|
||||
authed: true,
|
||||
version: SHELL_VERSION,
|
||||
dataDir: DATA_DIR,
|
||||
@@ -1426,7 +1571,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
}
|
||||
|
||||
if (!m[2] && req.method === 'DELETE') {
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||
try {
|
||||
fs.unlinkSync(layoutPath(name));
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user