UNPKG

@safeapi/safeapi

Version:

SafeAPI: Secure, deterministic, and tamper-resistant API policy engine for Node and browser.

105 lines (104 loc) 4.46 kB
import { deepFreeze } from "./shared"; import { stableHash } from "./shared/hash"; import { normalizeSafeApiPolicySchema, validateSafeApiPolicySchema } from "./SafeApiPolicySchema"; /** @internal */ export class SafeApiPolicyError extends Error { code; kind; constructor(message, code, kind) { super(message); this.name = "SafeApiPolicyError"; this.code = code; this.kind = kind || code; } } /** @internal */ export function validatePolicy(policy) { validateSafeApiPolicySchema(policy); } /** @internal */ export function normalizePolicy(policy) { // Injection defense: validate policy before normalization validateSafeApiPolicySchema(policy); // Block prototype pollution and dangerous objects at top level for (const key of Object.keys(policy)) { if (["__proto__", "constructor", "prototype"].includes(key)) { throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: prototype pollution (field: ${key})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection"); } const value = policy[key]; if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") { throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: dangerous value (field: ${key})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection"); } if (value && typeof value === "object") { const proto = Object.getPrototypeOf(value); if (proto && proto !== Object.prototype && proto !== Array.prototype) { throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: dangerous value prototype (field: ${key})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection"); } if (value instanceof Date || value instanceof RegExp || typeof value === "symbol" || typeof value === "function" || value instanceof Map || value instanceof Set) { throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: non-serializable value (field: ${key})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection"); } try { JSON.stringify(value); } catch (e) { throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: cyclic value (field: ${key})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection"); } } } const normalized = normalizeSafeApiPolicySchema(policy); return deepFreeze(normalized); } /** @internal */ export function computePolicyHash(policy) { const normalized = Object.isFrozen(policy) && "rules" in policy ? policy : normalizePolicy(policy); // Hash must include policyVersion and policyKind for determinism const hashInput = { ruleSetId: normalized.ruleSetId, policyVersion: normalized.policyVersion, policyKind: normalized.policyKind, rules: normalized.rules, guards: normalized.guards, }; return stableHash(hashInput); } /** @internal */ export function diffPolicies(oldPolicy, newPolicy) { const oldNormalized = normalizePolicy(oldPolicy); const newNormalized = normalizePolicy(newPolicy); const oldMap = new Map(); const newMap = new Map(); oldNormalized.rules.forEach((rule) => oldMap.set(rule.ruleId, rule)); newNormalized.rules.forEach((rule) => newMap.set(rule.ruleId, rule)); const addedRules = [...newMap.keys()].filter((id) => !oldMap.has(id)).sort(); const removedRules = [...oldMap.keys()].filter((id) => !newMap.has(id)).sort(); const changedRules = []; for (const ruleId of newMap.keys()) { if (!oldMap.has(ruleId)) continue; const oldRule = oldMap.get(ruleId); const newRule = newMap.get(ruleId); const oldHash = hashRule(oldRule); const newHash = hashRule(newRule); if (oldHash !== newHash) { changedRules.push({ ruleId, fromHash: oldHash, toHash: newHash, }); } } changedRules.sort((a, b) => a.ruleId.localeCompare(b.ruleId)); const policyId = newNormalized.ruleSetId || oldNormalized.ruleSetId; const diff = { policyId, fromVersion: oldNormalized.policyVersion ?? null, toVersion: newNormalized.policyVersion ?? null, addedRules, removedRules, changedRules, }; return diff; } function hashRule(rule) { return stableHash(rule); }