@safeapi/safeapi
Version:
SafeAPI: Secure, deterministic, and tamper-resistant API policy engine for Node and browser.
105 lines (104 loc) • 4.46 kB
JavaScript
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);
}