diff --git a/src/common/metrics/metrics.service.ts b/src/common/metrics/metrics.service.ts new file mode 100644 index 0000000..08a7d68 --- /dev/null +++ b/src/common/metrics/metrics.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Maximum allowed distinct values per metric label before collapsing to 'other' (#709). + * Prevents high-cardinality labels (wallet addresses, transaction IDs, UUIDs) from + * exploding Prometheus registry memory usage. + */ +export const MAX_LABEL_CARDINALITY = 100; + +const labelValuesSeen = new Map>(); + +/** + * Bounds the cardinality of metric label values to prevent memory leaks (#709). + */ +export function sanitizeMetricLabelValue(labelName: string, value: string): string { + if (!value) return 'unknown'; + + // Sanitize UUIDs / hex hashes / wallet addresses directly to 'redacted_id' + if (/^[a-fA-F0-9-]{36}$/.test(value) || /^G[A-Z2-7]{55}$/.test(value)) { + return 'redacted_id'; + } + + let seen = labelValuesSeen.get(labelName); + if (!seen) { + seen = new Set(); + labelValuesSeen.set(labelName, seen); + } + + if (seen.has(value)) { + return value; + } + + if (seen.size >= MAX_LABEL_CARDINALITY) { + return 'other'; + } + + seen.add(value); + return value; +} + +@Injectable() +export class MetricsService { + /** + * Sanitizes a metric label set before recording to Prometheus. + */ + sanitizeLabels(labels: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(labels)) { + sanitized[key] = sanitizeMetricLabelValue(key, value); + } + return sanitized; + } +} diff --git a/src/common/utils/config.utils.ts b/src/common/utils/config.utils.ts new file mode 100644 index 0000000..84a418e --- /dev/null +++ b/src/common/utils/config.utils.ts @@ -0,0 +1,33 @@ +/** + * Strict boolean environment variable parser (#706). + * + * Rejects arbitrary strings (e.g. "random_string", "2", "enabled") that JS Boolean() + * would incorrectly evaluate as true. + * + * Valid truthy strings: "true", "1", "yes", "on" + * Valid falsy strings: "false", "0", "no", "off" + * + * Throws an Error on invalid boolean configuration strings. + */ +export function parseBooleanEnv(value: unknown, defaultValue = false): boolean { + if (value === undefined || value === null || value === "") { + return defaultValue; + } + + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "string") { + const trimmed = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(trimmed)) { + return true; + } + if (["false", "0", "no", "off"].includes(trimmed)) { + return false; + } + throw new Error(`Invalid boolean configuration value: "${value}". Allowed values are true, false, 1, 0, yes, no, on, off.`); + } + + throw new Error(`Invalid boolean configuration type: ${typeof value}`); +} diff --git a/src/policy/policy.controller.ts b/src/policy/policy.controller.ts index fb4752b..f03fc30 100644 --- a/src/policy/policy.controller.ts +++ b/src/policy/policy.controller.ts @@ -476,8 +476,19 @@ export class PolicyController { // periodic write fails once the peer is gone, and both that and a // normal disconnect fire 'close' on the request, which completes the // stream and runs the teardown below. - const heartbeat = setInterval(() => { + // #708 — re-validate policy authorization on heartbeat to reject revoked credentials. + const heartbeat = setInterval(async () => { if (!subscriber.closed) { + try { + const currentPolicy = await this.policy.getPolicy(id); + if (!currentPolicy || currentPolicy.policyholder !== authedWallet) { + subscriber.error(new ForbiddenException('Policy authorization revoked')); + cleanup(); + return; + } + } catch { + /* ignore transient DB read errors during heartbeat */ + } subscriber.next({ type: 'heartbeat', data: { timestamp: Date.now() } }); } }, SSE_HEARTBEAT_INTERVAL_MS);