@safeapi/safeapi
Version:
SafeAPI: Secure, deterministic, and tamper-resistant API policy engine for Node and browser.
131 lines (130 loc) • 7.17 kB
JavaScript
import { SafeApiPolicyError } from "./SafeApiPolicyLifecycle";
function compare(operator, left, right) {
switch (operator) {
case "equals":
return left === right;
case "not_equals":
return left !== right;
case "gt":
return typeof left === "number" && typeof right === "number" && left > right;
case "lt":
return typeof left === "number" && typeof right === "number" && left < right;
case "contains":
return typeof left === "string" && typeof right === "string" && left.includes(right);
case "in":
return Array.isArray(left) && left.includes(right);
default:
return false;
}
}
function getFieldValue(context, field) {
// Only support top-level fields and metadata for now
if (field in context)
return context[field];
if (context.metadata && field in context.metadata)
return context.metadata[field];
return undefined;
}
/**
* Deterministic guard evaluation engine for SafeAPI.
* @internal
*/
export function evaluateGuards(guards = [], requestContext, options) {
// Tamper defense: check deep-frozen guards
if (!Object.isFrozen(guards)) {
throw new SafeApiPolicyError("SAFEAPI_POLICY_TAMPERED: guards not frozen", "SAFEAPI_POLICY_TAMPERED", "tamper");
}
if (!guards || guards.length === 0) {
return Object.freeze({ allowed: true, guardTrace: options?.trace ? Object.freeze([]) : undefined });
}
// Sort guards by guardId for determinism
const sorted = [...guards].sort((a, b) => a.guardId.localeCompare(b.guardId));
const matchingGuards = [];
const traces = [];
let logicalClock = 0;
for (const guard of sorted) {
// Tamper defense: check deep-frozen guard
if (!Object.isFrozen(guard)) {
throw new SafeApiPolicyError(`SAFEAPI_POLICY_TAMPERED: guard not frozen (guardId: ${guard.guardId})`, "SAFEAPI_POLICY_TAMPERED", "tamper");
}
// Injection defense: block prototype pollution and dangerous objects
for (const key of Object.keys(guard)) {
if (["__proto__", "constructor", "prototype"].includes(key)) {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: prototype pollution (field: ${key}, guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
const value = guard[key];
if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: non-plain object (guardId: ${guard.guardId})`, "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: non-plain object prototype (guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
}
}
// Tamper defense: check deep-frozen conditions
if (!Object.isFrozen(guard.conditions)) {
throw new SafeApiPolicyError(`SAFEAPI_POLICY_TAMPERED: conditions not frozen (guardId: ${guard.guardId})`, "SAFEAPI_POLICY_TAMPERED", "tamper");
}
const evaluatedConditions = guard.conditions.map(cond => {
// Tamper defense: check deep-frozen condition
if (!Object.isFrozen(cond)) {
throw new SafeApiPolicyError(`SAFEAPI_POLICY_TAMPERED: condition not frozen (guardId: ${guard.guardId})`, "SAFEAPI_POLICY_TAMPERED", "tamper");
}
for (const key of Object.keys(cond)) {
if (["__proto__", "constructor", "prototype"].includes(key)) {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: prototype pollution (field: ${key}, guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
}
if (typeof cond.value === "function" || typeof cond.value === "symbol" || typeof cond.value === "bigint") {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: dangerous value (field: value, guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
if (cond.value && typeof cond.value === "object") {
const proto = Object.getPrototypeOf(cond.value);
if (proto && proto !== Object.prototype && proto !== Array.prototype) {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: dangerous value prototype (field: value, guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
if (cond.value instanceof Date || cond.value instanceof RegExp || typeof cond.value === "symbol" || typeof cond.value === "function" || cond.value instanceof Map || cond.value instanceof Set) {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: non-serializable value (field: value, guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
try {
JSON.stringify(cond.value);
}
catch (e) {
throw new SafeApiPolicyError(`SAFEAPI_GUARD_INJECTION_BLOCKED: cyclic value (field: value, guardId: ${guard.guardId})`, "SAFEAPI_GUARD_INJECTION_BLOCKED", "injection");
}
}
const left = getFieldValue(requestContext, cond.field);
return Object.freeze({
field: cond.field,
operator: cond.operator,
value: cond.value,
matched: compare(cond.operator, left, cond.value),
});
});
const allMatch = evaluatedConditions.every(c => c.matched);
traces.push(Object.freeze({
guardId: guard.guardId,
matched: allMatch,
effect: guard.effect,
evaluatedConditions: Object.freeze(evaluatedConditions),
evaluatedAt: `logical-${++logicalClock}`,
}));
if (allMatch) {
matchingGuards.push(guard);
}
}
// Deny always wins if any matching deny guard exists
const denyGuard = matchingGuards.find(g => g.effect === "deny");
if (denyGuard) {
return Object.freeze({ allowed: false, matchedGuard: denyGuard.guardId, guardTrace: options?.trace ? Object.freeze(traces) : undefined });
}
// Otherwise, allow wins if any matching allow guard exists
const allowGuard = matchingGuards.find(g => g.effect === "allow");
if (allowGuard) {
return Object.freeze({ allowed: true, matchedGuard: allowGuard.guardId, guardTrace: options?.trace ? Object.freeze(traces) : undefined });
}
// No matches, allow by default
return Object.freeze({ allowed: true, guardTrace: options?.trace ? Object.freeze(traces) : undefined });
}