Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/common/metrics/metrics.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>();

/**
* 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<string>();
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<string, string>): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(labels)) {
sanitized[key] = sanitizeMetricLabelValue(key, value);
}
return sanitized;
}
}
33 changes: 33 additions & 0 deletions src/common/utils/config.utils.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
13 changes: 12 additions & 1 deletion src/policy/policy.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down