fix(ca): gate per-service cert/key download behind TOTP+admin scope; require explicit PFX password; add rate limit (DC-076) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
DashCaddy Polish Loop
2026-08-18 16:40:28 -07:00
parent e8c5a7a1fb
commit 2fef1c47e5
3 changed files with 462 additions and 9 deletions
+97 -7
View File
@@ -123,17 +123,106 @@ module.exports = function(ctx) {
res.send(script);
}, 'ca-install-script'));
// DC-076: per-service cert/key download — TOTP + admin scope required.
// Pre-fix this endpoint (a) had a hardcoded `password = 'dashcaddy'` default
// for the PFX format — a default credential published in source; (b) was
// public-listed in middleware.js PUBLIC_ROUTES (TOTP bypassed when TOTP is
// disabled — single ops command or fresh-install setup state), and (c)
// accepted ANY TOTP-authenticated scope (read scope was enough to pull
// private keys). Fix: require explicit password (no default), require
// TOTP/session (dropped from PUBLIC_ROUTES — see middleware.js), and
// require `admin` scope at the route layer as defense-in-depth against
// future middleware-ordering mistakes.
const CA_CERT_DOMAINS_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
// Per-DC-076: PFX password now required, ≥ 8 chars, no `=` (pkcs12
// interprets `=` as a base64 padding marker that downstream tooling
// can mis-handle; reject it to keep the password copy-paste-safe).
const CA_PFX_PASSWORD_RE = /^[A-Za-z0-9!@#%^_+,.~:-]{8,64}$/;
const CA_CERT_RATE_LIMIT = { windowMs: 60_000, max: 10 };
const caCertRateBuckets = new Map(); // ip -> { count, resetAt }
function caCertRateLimit(ip) {
const now = Date.now();
const b = caCertRateBuckets.get(ip);
if (!b || b.resetAt <= now) {
caCertRateBuckets.set(ip, { count: 1, resetAt: now + CA_CERT_RATE_LIMIT.windowMs });
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - 1 };
}
if (b.count >= CA_CERT_RATE_LIMIT.max) {
return { allowed: false, remaining: 0, retryAfterMs: b.resetAt - now };
}
b.count += 1;
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - b.count };
}
function requireCaCertAdminScope(req, res) {
// TOTP is enforced by `totpAuthMiddleware` globally. Here we additionally
// require the `admin` scope — even a read-scope API key or read-scope
// JWT must NOT be able to pull a private key. Auth context is mounted on
// `req.auth` by the upstream middlewares.
const auth = req.auth || {};
const scope = Array.isArray(auth.scope) ? auth.scope : [];
if (!scope.includes('admin')) {
ctx.errorResponse(res, 403,
'Admin scope required to download per-service private keys. Re-authenticate with an admin-scoped credential.',
{ code: 'DC-076_INSUFFICIENT_SCOPE', requiredScope: 'admin', actualScope: scope });
return false;
}
return true;
}
// Generate and download SSL certificate for a service
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
const { domain } = req.params;
const { password = 'dashcaddy', format = 'pfx' } = req.query;
if (!requireCaCertAdminScope(req, res)) return;
if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) {
throw new ValidationError('Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).');
const { domain } = req.params;
// DC-076: password is REQUIRED for the pfx format (no `=`) and must
// be ≥ 8 chars. Previously `password = 'dashcaddy'` — a hardcoded
// default that silently signed every PFX with the same published
// password. Other formats (key, pem, crt, fullchain) do not need a
// password and ignore the param.
const wantsPfx = !req.query.format || req.query.format === 'pfx';
let password = req.query.password;
if (wantsPfx) {
if (typeof password !== 'string' || password === '') {
return ctx.errorResponse(res, 400,
'PFX format requires an explicit `password` query param (8-64 chars, no `=`). '
+ 'A published default is unsafe — pick your own.',
{ code: 'DC-076_PASSWORD_REQUIRED' });
}
if (!CA_PFX_PASSWORD_RE.test(password)) {
return ctx.errorResponse(res, 400,
'PFX password must be 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-].',
{ code: 'DC-076_PASSWORD_INVALID' });
}
} else {
// For non-PFX formats, still reject `=` in the password so a copy-paste
// mistake can't accidentally inject a base64 padding token into a path
// someone else might log.
if (password !== undefined && (typeof password !== 'string' || password.includes('='))) {
return ctx.errorResponse(res, 400, 'password (if supplied) must be a string without `=`.',
{ code: 'DC-076_PASSWORD_INVALID' });
}
}
if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) {
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
// DC-076: per-IP rate limit — each cert request forks an `openssl` process
// and writes to disk. An authenticated admin polling the endpoint in a
// loop could exhaust CPU/IO. 10 req/min/IP is enough for normal use
// (regenerate one cert, check 4 formats, done) and tight enough to stop
// a runaway client.
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
const rl = caCertRateLimit(clientIp);
if (!rl.allowed) {
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
return ctx.errorResponse(res, 429,
`Rate limit exceeded for /api/v1/ca/cert/* (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs/1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
{ code: 'DC-076_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
}
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
if (!CA_CERT_DOMAINS_RE.test(domain)) {
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`,
{ code: 'DC-076_DOMAIN_INVALID' });
}
const pkiPath = platformPaths.pkiDir;
@@ -240,8 +329,9 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
}
}, 'ca-cert'));
// List generated certificates
// List generated certificates (DC-076: TOTP-gated; previously public-listed)
router.get('/certs', ctx.asyncHandler(async (req, res) => {
if (!requireCaCertAdminScope(req, res)) return;
const certsDir = platformPaths.generatedCertsDir;
if (!await exists(certsDir)) {