Files
picpeak/backend/src/utils/publicTokenGuards.js
T
Luca d543949188 feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
2026-05-26 18:18:51 +02:00

158 lines
5.9 KiB
JavaScript

/**
* publicTokenGuards — shared validators for the public token tables
* (`contract_action_tokens`, `quote_action_tokens`). Centralises the
* checks that every public-facing route MUST run before doing work,
* so future routes can't accidentally skip a guard.
*
* What this enforces:
* 1. **Existence** — 404 when the token doesn't match a row.
* 2. **Expiry** — 410 when `expires_at` is in the past
* OR is NULL (defensive: NULL = expired,
* not "valid forever" — historical bug).
* 3. **One-shot semantics** — when `requireUnused: true`, 409 if
* `used_at` is already set. Prevents
* replay of leaked tokens on the upload
* path. The sign path historically allowed
* re-signing for in-browser flows; opt in
* per call site.
* 4. **Attempt throttling** — non-existent tokens increment a per-IP
* counter; the IP is locked out for 15 min
* after 20 invalid attempts. Mitigates the
* token-prefix brute force route that
* standard rate-limiters don't catch
* (large token space, low miss rate per
* IP, but distributed crawlers add up).
*
* Returns the validated token row on success. Sends the appropriate
* HTTP response and returns `null` on failure — the caller must check
* for null and `return` immediately.
*/
const { db } = require('../database/db');
const { clientIpForAudit } = require('./clientIp');
const logger = require('./logger');
// In-memory bad-attempt counter. Per-process; cleared on restart.
// Keyed by IP. Each entry: { count, firstAt }. We could persist this
// in app_settings or a dedicated table, but in-memory is simpler and
// good enough for the threat (distributed brute force is the only
// case where IP locking helps anyway, and that needs more than one
// IP to be effective).
const BAD_ATTEMPT_LIMIT = 20;
const BAD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const badAttempts = new Map();
function recordBadAttempt(ip) {
if (!ip) return;
const now = Date.now();
const entry = badAttempts.get(ip);
if (!entry || (now - entry.firstAt) > BAD_ATTEMPT_WINDOW_MS) {
badAttempts.set(ip, { count: 1, firstAt: now });
return;
}
entry.count += 1;
}
function isIpLocked(ip) {
if (!ip) return false;
const entry = badAttempts.get(ip);
if (!entry) return false;
if ((Date.now() - entry.firstAt) > BAD_ATTEMPT_WINDOW_MS) {
badAttempts.delete(ip);
return false;
}
return entry.count >= BAD_ATTEMPT_LIMIT;
}
/**
* Validate a public action token. Returns the token row on success,
* sends a response + returns null on failure.
*
* @param {object} req Express request (for IP)
* @param {object} res Express response (to send errors)
* @param {object} opts
* @param {string} opts.tableName 'contract_action_tokens' | 'quote_action_tokens'
* @param {string} opts.token 64-hex token string
* @param {boolean} [opts.requireUnused] refuse when used_at is set (default false)
*/
async function loadActionToken(req, res, opts) {
const { tableName, token, requireUnused = false } = opts;
const ip = clientIpForAudit(req);
if (isIpLocked(ip)) {
res.status(429).json({
error: 'Too many invalid token attempts. Try again in 15 minutes.',
code: 'TOKEN_LOOKUP_LOCKED',
});
return null;
}
const row = await db(tableName).where({ token }).first();
if (!row) {
recordBadAttempt(ip);
res.status(404).json({ error: 'Not found' });
return null;
}
// Defensive: NULL expires_at counts as expired. Historical bug —
// old seed rows could land without an expiry value, granting
// permanent unauthenticated access. We refuse rather than guess.
if (!row.expires_at) {
logger.warn('publicTokenGuards: token has NULL expires_at — refusing', {
tableName, tokenPrefix: token.slice(0, 12),
});
res.status(410).json({ error: 'This link has expired', code: 'TOKEN_NO_EXPIRY' });
return null;
}
if (new Date(row.expires_at).getTime() < Date.now()) {
res.status(410).json({ error: 'This link has expired', code: 'TOKEN_EXPIRED' });
return null;
}
if (requireUnused && row.used_at) {
res.status(409).json({
error: 'This link has already been used',
code: 'TOKEN_ALREADY_USED',
});
return null;
}
return row;
}
/**
* Pre-multer guard for upload routes. Runs the same validation as
* loadActionToken but DOES NOT mutate state — it just rejects bad
* tokens before multer reads the request body and writes to disk.
* Without this, a captured/expired token can DoS disk by spamming
* uploads that get rejected post-write.
*
* Wired in as middleware before `multer.single(...)`.
*/
function preMulterTokenGuard(tableName) {
return async (req, res, next) => {
try {
const token = req.params.token;
if (!token || !/^[a-f0-9]{64}$/i.test(token)) {
return res.status(400).json({ error: 'Invalid token format' });
}
const row = await loadActionToken(req, res, { tableName, token, requireUnused: true });
if (!row) return; // loadActionToken already responded
// Attach for downstream handler — saves a duplicate DB lookup.
req.publicTokenRow = row;
next();
} catch (err) {
logger.error('preMulterTokenGuard: unexpected error', { err: err.message });
return res.status(500).json({ error: 'Internal error' });
}
};
}
module.exports = {
loadActionToken,
preMulterTokenGuard,
// Exported for tests + future routes that need the same lock
// surface (e.g. payment-check actions).
_internal: { recordBadAttempt, isIpLocked, badAttempts },
};