Files
tools-legacy/tools/permit.js
T
adebisi-fa 643e2c60be fix(security,reliability): address code-review findings
HIGH
- async handlers: Express 4 does not catch rejected promises and Node 22
  exits on unhandled rejections — a malformed body ({} to /cropper/dequeue
  etc.) could hang a request or kill the process. Every cropper route is
  now wrapped (throw → 500 via next), request bodies are validated up
  front (400), and JSON.parse of queue items is guarded. Server-wide
  error middleware added, honouring err.status (413 for oversized bodies).
- error pages HTML-escape all interpolated values — the login failure page
  rendered upstream (portal) error text raw.

MEDIUM
- outbound calls carry AbortSignal timeouts (5s OpenFGA verify, 15s
  cropper upstreams) — a hung upstream can no longer pin a request.
- body limits scoped: /cropper gets 50mb (picture data URLs), everything
  else 1mb (previously 50mb globally, memory-DoS surface pre-auth).
- cropper save validates the client-supplied domain against the catalog;
  picture fetches validate the Minio object key (id-domains/ prefix, no ..).

LOW
- static assets served before the session middleware (no PocketBase load
  per asset); landing header builds DOM nodes with textContent instead of
  innerHTML; picture load checks upstream ok.

5 new tests (40 total) covering malformed bodies, malformed queue items,
unknown domains, object-key traversal, and error-page escaping.
2026-10-01 03:12:02 +01:00

127 lines
4.1 KiB
JavaScript

// Domain permission checking against the portal OpenFGA verify endpoint.
// Mirrors fingerprint-agent internal/permit: `domain:<code>` first, then a
// `domain:idid` fallback — either allow passes, and every failure closes.
// Results are cached per operator phone for DOMAIN_TTL_MS.
export const DOMAIN_TTL_MS = 5 * 60 * 1000;
export const CACHE_FRESH = 'CACHE_FRESH';
/** Outbound OpenFGA verify timeout — a hung portal must not pin a request. */
export const VERIFY_TIMEOUT_MS = 5000;
export function stripPhonePrefix(phone) {
return (phone || '').replace(/^\+/, '');
}
async function verifyOnce(fetchImpl, portalUrl, phone, object, relation = 'capture_biometric') {
const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subsystem: 'idid',
user: `user:${phone}`,
relation,
object,
}),
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
});
if (r.status !== 200) throw new Error(`verify status ${r.status}`);
const out = await r.json();
if (!out?.ok) throw new Error('verify returned ok=false');
return !!out.allowed;
}
// true = allowed, false = denied; throws when neither object could answer.
export async function checkDomain(fetchImpl, portalUrl, phone, code) {
let answered = false;
for (const object of [`domain:${code}`, 'domain:idid']) {
try {
const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object, 'capture_biometric');
answered = true;
if (allowed) return true;
} catch {
// try the fallback object; if neither answers we fail closed below
}
}
if (!answered) throw new Error(`verify failed for domain:${code} and domain:idid`);
return false;
}
/**
* Single-object permission check for an arbitrary relation — fail closed:
* returns true only for an explicit ok+allowed, false on denial, and throws
* when the check itself fails.
*/
export async function checkPermission(fetchImpl, portalUrl, phone, { relation, object }) {
return verifyOnce(fetchImpl, portalUrl, phone, object, relation);
}
/**
* Cached `crop_image` permission on domain:idid for the cropper. Same shape as
* permittedDomains: 5-minute per-phone cache, `force` bypasses the cache for
* live checks and refuses while fresh, failures fail closed (denied).
* Returns { allowed, checkedAt }.
*/
export async function cropPermission({
phone,
portalUrl,
fetchImpl = fetch,
cache,
ttlMs = DOMAIN_TTL_MS,
now = Date.now,
force = false,
}) {
const hit = cache.get(phone);
const fresh = !!(hit && now() - hit.checkedAt < ttlMs);
if (fresh && !force) {
return { allowed: hit.allowed, checkedAt: hit.checkedAt };
}
const checkedAt = now();
let allowed = false;
try {
allowed = await checkPermission(fetchImpl, portalUrl, phone, {
relation: 'crop_image',
object: 'domain:idid',
});
} catch {
allowed = false; // fail closed
}
cache.set(phone, { allowed, checkedAt });
return { allowed, checkedAt };
}
/**
* Permitted domain codes for phone, cached per phone for ttlMs.
* force=true re-checks everything; while the cache is still fresh it refuses
* with an error whose .code is CACHE_FRESH. Unanswered checks exclude the
* domain (fail closed). Returns { codes, checkedAt }.
*/
export async function permittedDomains({
domains,
phone,
portalUrl,
fetchImpl = fetch,
cache,
ttlMs = DOMAIN_TTL_MS,
now = Date.now,
force = false,
}) {
const hit = cache.get(phone);
const fresh = !!(hit && now() - hit.checkedAt < ttlMs);
if (fresh && !force) return { codes: hit.codes, checkedAt: hit.checkedAt };
if (fresh && force) {
const e = new Error('permission cache is still fresh');
e.code = CACHE_FRESH;
throw e;
}
const settled = await Promise.all(
domains.map((d) =>
checkDomain(fetchImpl, portalUrl, phone, d.code)
.then((ok) => (ok ? d.code : null))
.catch(() => null)
)
);
const codes = settled.filter(Boolean);
const checkedAt = now();
cache.set(phone, { codes, checkedAt });
return { codes, checkedAt };
}