fix(ca): gate per-service cert/key download behind TOTP+admin scope; require explicit PFX password; add rate limit (DC-076) [glm-grade=A]
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user