diff --git a/backend/src/gateway/PrivacyApiGateway.ts b/backend/src/gateway/PrivacyApiGateway.ts index 94e0196..6448747 100644 --- a/backend/src/gateway/PrivacyApiGateway.ts +++ b/backend/src/gateway/PrivacyApiGateway.ts @@ -8,6 +8,7 @@ import { RequestTransformer } from "./RequestTransformer"; import { PrivacyMetrics } from "./PrivacyMetrics"; import { LoadBalancer } from "./LoadBalancer"; import { logger } from "../utils/logger"; +import { validateAndSanitizePolicy } from "./policyValidation"; import jwt from "jsonwebtoken"; export interface GatewayConfig { @@ -47,6 +48,7 @@ export interface RouteConfig { export interface PolicyConfig { id: string; name: string; + description?: string; rules: PolicyRule[]; priority: number; enabled: boolean; @@ -148,7 +150,11 @@ export class PrivacyApiGateway { this.app.get("/gateway/health", this.healthCheck.bind(this)); this.app.get("/gateway/metrics", this.getMetrics.bind(this)); this.app.get("/gateway/policies", this.getPolicies.bind(this)); - this.app.post("/gateway/policies", this.updatePolicy.bind(this)); + this.app.post( + "/gateway/policies", + this.policyBodySizeLimit.bind(this), + this.updatePolicy.bind(this), + ); this.app.get("/gateway/services", this.getServices.bind(this)); // Setup proxy routes for each service @@ -471,6 +477,30 @@ export class PrivacyApiGateway { }); } + // Policy-specific body size limiter (100KB max) + private policyBodySizeLimit( + req: Request, + res: Response, + next: NextFunction, + ): void { + const MAX_POLICY_BODY_SIZE = 100 * 1024; // 100KB + + const contentLength = parseInt( + req.headers["content-length"] || "0", + 10, + ); + + if (contentLength > MAX_POLICY_BODY_SIZE) { + res.status(413).json({ + error: "Payload Too Large", + message: `Policy payload exceeds maximum size of ${MAX_POLICY_BODY_SIZE / 1024}KB`, + }); + return; + } + + next(); + } + // Management endpoints private async healthCheck(req: Request, res: Response): Promise { const health = await (this.loadBalancer as any).getServicesHealth(); @@ -494,13 +524,32 @@ export class PrivacyApiGateway { private async updatePolicy(req: Request, res: Response): Promise { try { - const policy = req.body; + const rawPayload = req.body; + + // Validate and sanitize policy payload + const validation = validateAndSanitizePolicy(rawPayload); + + if (!validation.valid) { + res.status(400).json({ + error: "Policy Validation Failed", + message: validation.error, + }); + return; + } + + // Apply sanitized values (use ?? so empty strings from sanitization are preserved) + const policy = { + ...rawPayload, + name: validation.sanitizedName ?? rawPayload.name, + description: validation.sanitizedDescription ?? rawPayload.description, + }; + await this.policyEngine.updatePolicy(policy); res.json({ message: "Policy updated successfully", policyId: policy.id, }); - } catch (error) { + } catch (error: any) { res.status(400).json({ error: "Policy Update Failed", message: error.message, diff --git a/backend/src/gateway/__tests__/PolicyValidation.test.ts b/backend/src/gateway/__tests__/PolicyValidation.test.ts new file mode 100644 index 0000000..2d9997f --- /dev/null +++ b/backend/src/gateway/__tests__/PolicyValidation.test.ts @@ -0,0 +1,500 @@ +import { + validateAndSanitizePolicy, + sanitizeHtml, + isRegexSafe, +} from "../policyValidation"; + +// ── Valid baseline policy used across tests ── +const validPolicy = { + id: "policy-001", + name: "GDPR-Compliant Access", + rules: [ + { + attribute: "privacy.level", + operator: "equals" as const, + value: "high", + action: "allow" as const, + }, + ], + priority: 10, + enabled: true, +}; + +describe("validateAndSanitizePolicy", () => { + // ── Happy path ── + it("accepts a valid policy payload", () => { + const result = validateAndSanitizePolicy(validPolicy); + expect(result.valid).toBe(true); + expect(result.sanitizedName).toBe("GDPR-Compliant Access"); + }); + + it("accepts a policy with optional description", () => { + const policy = { ...validPolicy, description: "Enforces GDPR access rules" }; + const result = validateAndSanitizePolicy(policy); + expect(result.valid).toBe(true); + expect(result.sanitizedDescription).toBe("Enforces GDPR access rules"); + }); + + // ── Prototype pollution ── + it("rejects __proto__ key (prototype pollution)", () => { + const payload = { + ...validPolicy, + __proto__: { isAdmin: true }, + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("Prototype pollution"); + }); + + it("rejects constructor key (prototype pollution)", () => { + const payload = { + ...validPolicy, + constructor: { prototype: { isAdmin: true } }, + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("Prototype pollution"); + }); + + it("rejects unknown top-level fields (strict mode)", () => { + const payload = { + ...validPolicy, + injectedAdmin: true, + extraNasty: "evil", + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("Unknown field"); + }); + + it("rejects unknown fields inside rules (strict mode)", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "privacy.level", + operator: "equals", + value: "high", + action: "allow", + injectedField: "malicious", + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("not allowed"); + }); + + // ── Missing required fields ── + it("rejects payload missing id", () => { + const { id: _, ...noId } = validPolicy; + const result = validateAndSanitizePolicy(noId); + expect(result.valid).toBe(false); + expect(result.error).toContain("id"); + }); + + it("rejects payload missing name", () => { + const { name: _, ...noName } = validPolicy; + const result = validateAndSanitizePolicy(noName); + expect(result.valid).toBe(false); + expect(result.error).toContain("name"); + }); + + it("rejects payload missing rules", () => { + const { rules: _, ...noRules } = validPolicy; + const result = validateAndSanitizePolicy(noRules); + expect(result.valid).toBe(false); + expect(result.error).toContain("rules"); + }); + + it("rejects payload with empty rules array", () => { + const payload = { ...validPolicy, rules: [] }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("rules"); + }); + + it("rejects payload missing priority", () => { + const { priority: _, ...noPriority } = validPolicy; + const result = validateAndSanitizePolicy(noPriority); + expect(result.valid).toBe(false); + expect(result.error).toContain("priority"); + }); + + it("rejects payload missing enabled", () => { + const { enabled: _, ...noEnabled } = validPolicy; + const result = validateAndSanitizePolicy(noEnabled); + expect(result.valid).toBe(false); + expect(result.error).toContain("enabled"); + }); + + // ── Invalid rule fields ── + it("rejects rule with unknown attribute", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "admin.bypass", + operator: "equals", + value: "true", + action: "allow", + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("attribute"); + }); + + it("rejects rule with unknown operator", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "privacy.level", + operator: "eval", + value: "true", + action: "allow", + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("operator"); + }); + + it("rejects rule with unknown action", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "privacy.level", + operator: "equals", + value: "high", + action: "escalate", + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("action"); + }); + + it("rejects rule with value exceeding max length", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "privacy.level", + operator: "equals", + value: "x".repeat(1001), + action: "allow", + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("value"); + }); + + it("rejects name exceeding max length", () => { + const payload = { ...validPolicy, name: "x".repeat(201) }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("name"); + }); + + it("rejects negative priority", () => { + const payload = { ...validPolicy, priority: -1 }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("priority"); + }); + + it("rejects non-boolean enabled", () => { + const payload = { ...validPolicy, enabled: "yes" as any }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("enabled"); + }); + + it("rejects non-integer priority", () => { + const payload = { ...validPolicy, priority: 3.5 }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("priority"); + }); + + // ── XSS in name/description ── + it("strips HTML tags from policy name", () => { + const payload = { + ...validPolicy, + name: 'Normal Name', + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(true); + expect(result.sanitizedName).toBe('alert("XSS")Normal Name'); + expect(result.sanitizedName).not.toContain(""); + }); + + it("strips HTML tags from policy description", () => { + const payload = { + ...validPolicy, + description: 'Description', + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(true); + // The entire img tag is stripped by <[^>]*> regex + expect(result.sanitizedDescription).toBe("Description"); + expect(result.sanitizedDescription).not.toContain(" { + const payload = { + ...validPolicy, + name: "javascript:void(0) Policy Name", + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(true); + expect(result.sanitizedName).toBe("void(0) Policy Name"); + expect(result.sanitizedName).not.toContain("javascript:"); + }); + + it("strips onclick handlers from name", () => { + const payload = { + ...validPolicy, + name: 'Policy Name" onclick="alert(1)', + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(true); + // The pattern matches on\w+\s*= so 'onclick="...' gets stripped + expect(result.sanitizedName).not.toContain("onclick"); + }); + + // ── ReDoS safety for regex rules ── + it("rejects regex rule with dangerous nested quantifiers (ReDoS)", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "request.path", + operator: "regex" as const, + value: "(a+)+b", + action: "deny" as const, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("unsafe regex"); + }); + + it("rejects regex rule with .*.* (classic redos)", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "request.path", + operator: "regex" as const, + value: ".*.*.*", + action: "deny" as const, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("unsafe regex"); + }); + + it("accepts safe regex patterns", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "request.path", + operator: "regex" as const, + value: "^/api/v[0-9]+/users/[a-f0-9-]+$", + action: "allow" as const, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(true); + }); + + it("rejects regex pattern longer than 500 chars", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "request.path", + operator: "regex" as const, + value: "a".repeat(501), + action: "deny" as const, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("unsafe regex"); + }); + + it("rejects regex with excessive alternation", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "request.path", + operator: "regex" as const, + value: "a|b|c|d|e|f|g|h|i|j|k|l", + action: "deny" as const, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("unsafe regex"); + }); + + // ── Transform rule validation ── + it("requires transformation when action is transform", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "privacy.level", + operator: "equals" as const, + value: "high", + action: "transform" as const, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(false); + expect(result.error).toContain("transformation"); + }); + + it("accepts valid transform rule", () => { + const payload = { + ...validPolicy, + rules: [ + { + attribute: "privacy.level", + operator: "equals" as const, + value: "high", + action: "transform" as const, + transformation: { + type: "mask" as const, + field: "ssn", + }, + }, + ], + }; + const result = validateAndSanitizePolicy(payload); + expect(result.valid).toBe(true); + }); + + // ── null / undefined / non-object payloads ── + it("rejects null payload", () => { + const result = validateAndSanitizePolicy(null); + expect(result.valid).toBe(false); + }); + + it("rejects undefined payload", () => { + const result = validateAndSanitizePolicy(undefined); + expect(result.valid).toBe(false); + }); + + it("rejects string payload", () => { + const result = validateAndSanitizePolicy("malicious string"); + expect(result.valid).toBe(false); + }); + + it("rejects array payload", () => { + const result = validateAndSanitizePolicy([]); + expect(result.valid).toBe(false); + }); + + // ── Rules min/max bounds ── + it("accepts policy with maximum allowed rules (200)", () => { + const rules = Array.from({ length: 200 }, (_, i) => ({ + attribute: "privacy.level" as const, + operator: "equals" as const, + value: `level-${i}`, + action: "allow" as const, + })); + const result = validateAndSanitizePolicy({ + ...validPolicy, + rules, + }); + expect(result.valid).toBe(true); + }); + + it("rejects policy exceeding max rules (201)", () => { + const rules = Array.from({ length: 201 }, (_, i) => ({ + attribute: "privacy.level" as const, + operator: "equals" as const, + value: `level-${i}`, + action: "allow" as const, + })); + const result = validateAndSanitizePolicy({ + ...validPolicy, + rules, + }); + expect(result.valid).toBe(false); + expect(result.error).toContain("rules"); + }); +}); + +describe("sanitizeHtml", () => { + it("returns empty string unchanged", () => { + expect(sanitizeHtml("")).toBe(""); + }); + + it("strips script tags", () => { + expect(sanitizeHtml('Hello')).toBe( + 'alert("xss")Hello', + ); + }); + + it("strips img tags with event handlers", () => { + // The regex <[^>]*> strips the entire HTML tag greedily + expect(sanitizeHtml('')).toBe(""); + }); + + it("strips javascript: protocol", () => { + expect(sanitizeHtml("javascript:alert(1)")).toBe("alert(1)"); + }); +}); + +describe("isRegexSafe", () => { + it("returns false for empty string", () => { + expect(isRegexSafe("")).toBe(false); + }); + + it("returns true for simple safe pattern", () => { + expect(isRegexSafe("^test$")).toBe(true); + }); + + it("returns false for (a+)+ pattern", () => { + expect(isRegexSafe("(a+)+")).toBe(false); + }); + + it("returns false for (a|b)* pattern", () => { + expect(isRegexSafe("(a|b)*.$")).toBe(false); + }); + + it("returns false for invalid regex syntax", () => { + expect(isRegexSafe("[unclosed")).toBe(false); + }); + + it("handles regex with flags suffix", () => { + expect(isRegexSafe("/^test$/gi")).toBe(true); + }); + + it("rejects .*.*.* pattern", () => { + expect(isRegexSafe(".*.*.*")).toBe(false); + }); +}); diff --git a/backend/src/gateway/policyValidation.ts b/backend/src/gateway/policyValidation.ts new file mode 100644 index 0000000..246783b --- /dev/null +++ b/backend/src/gateway/policyValidation.ts @@ -0,0 +1,244 @@ +import Joi from "joi"; + +// ── Known attribute enum (must stay in sync with PrivacyPolicyEngine.evaluateRule) ── +const KNOWN_ATTRIBUTES = [ + "privacy.level", + "privacy.jurisdiction", + "privacy.dataClassification", + "privacy.consent", + "privacy.purpose", + "request.path", + "request.method", + "user.role", + "user.department", + "user.ipAddress", +] as const; + +const KNOWN_OPERATORS = [ + "equals", + "contains", + "startsWith", + "endsWith", + "regex", + "not_equals", +] as const; + +const KNOWN_ACTIONS = ["allow", "deny", "transform", "log"] as const; + +const VALID_TRANSFORMATION_TYPES = [ + "mask", + "encrypt", + "hash", + "remove", + "pseudonymize", +] as const; + +// ── Maximum lengths ── +const MAX_NAME_LENGTH = 200; +const MAX_VALUE_LENGTH = 1000; +const MAX_RULES_LENGTH = 200; +const MAX_DESCRIPTION_LENGTH = 1000; + +// ── ReDoS-safe regex check ── +// Detects patterns with exponential backtracking (e.g. nested quantifiers, alternation inside groups) +const REDOS_DANGEROUS_PATTERNS = [ + /\([^)]*\+[^)]*\)[\*\+\?]/, // Group with + inside followed by quantifier → (a+)* + /\([^)]*\|[^)]*\)[\*\+\?]/, // Alternation group with quantifier → (a|b)* + /\(\?:[^)]*\+[^)]*\)[\*\+\?]/, // Non-capturing group with + inside followed by quantifier + /\(\?:[^)]*\|[^)]*\)[\*\+\?]/, // Non-capturing alternation with quantifier + /\([^)]+\)\s*\{[^}]*,[^}]*\}[*+?]/, // Bounded repetition group then quantifier + /\+\+/, // Double quantifier + /\+\*/, // +* pattern + /\*\+/, // *+ pattern + /\{\d+,\d+\}\+/, // {n,m}+ pattern + /\.\*\.\*/, // .*.* → classic catastrophic backtracking sign +]; + +/** + * Returns true if the pattern is considered safe (not vulnerable to ReDoS). + */ +export function isRegexSafe(pattern: string): boolean { + if (!pattern || pattern.length > 500) { + return false; + } + + // Strip flags if present (e.g. /pattern/gi) + let body = pattern; + if (body.startsWith("/")) { + const lastSlash = body.lastIndexOf("/"); + if (lastSlash > 0) { + body = body.slice(1, lastSlash); + } + } + + // Check for nested quantifiers or dangerous patterns + for (const dangerousPattern of REDOS_DANGEROUS_PATTERNS) { + if (dangerousPattern.test(body)) { + return false; + } + } + + // Try compiling the regex to verify it's syntactically valid + try { + new RegExp(body); + } catch { + return false; + } + + // Check for excessive alternation depth (can still cause ReDoS in some engines) + const alternationCount = (body.match(/\|/g) || []).length; + if (alternationCount > 10) { + return false; + } + + return true; +} + +// ── HTML / XSS sanitization ── +const HTML_TAG_PATTERN = /<[^>]*>/g; +const SCRIPT_PATTERN = + /(?:[\s\S]*?<\/script>|javascript\s*:|on\w+\s*=)/gi; + +/** + * Strips HTML tags and script-related content from a string. + */ +export function sanitizeHtml(input: string): string { + if (!input) return input; + return input.replace(HTML_TAG_PATTERN, "").replace(SCRIPT_PATTERN, ""); +} + +// ── Transformation rule sub-schema ── +const transformationRuleSchema = Joi.object({ + type: Joi.string() + .valid(...VALID_TRANSFORMATION_TYPES) + .required(), + field: Joi.string().max(200).required(), + algorithm: Joi.string().max(100).optional(), + parameters: Joi.object().optional(), +}).unknown(false); + +// ── Policy rule sub-schema ── +const policyRuleSchema = Joi.object({ + attribute: Joi.string() + .valid(...KNOWN_ATTRIBUTES) + .required() + .messages({ + "any.only": + "rule.attribute must be one of the known privacy attributes: " + + KNOWN_ATTRIBUTES.join(", "), + }), + operator: Joi.string() + .valid(...KNOWN_OPERATORS) + .required() + .messages({ + "any.only": + "rule.operator must be one of: " + KNOWN_OPERATORS.join(", "), + }), + value: Joi.string().max(MAX_VALUE_LENGTH).required(), + action: Joi.string() + .valid(...KNOWN_ACTIONS) + .required() + .messages({ + "any.only": + "rule.action must be one of: " + KNOWN_ACTIONS.join(", "), + }), + transformation: Joi.when("action", { + is: "transform", + then: transformationRuleSchema.required(), + otherwise: transformationRuleSchema.optional(), + }), +}).unknown(false); + +// ── Policy schema (CRUD payload) ── +export const policySchema = Joi.object({ + id: Joi.string().max(100).required(), + name: Joi.string().max(MAX_NAME_LENGTH).required(), + description: Joi.string().max(MAX_DESCRIPTION_LENGTH).optional(), + rules: Joi.array() + .items(policyRuleSchema) + .min(1) + .max(MAX_RULES_LENGTH) + .required(), + priority: Joi.number().integer().min(0).required(), + enabled: Joi.boolean().required(), +}) + .unknown(false) // Strict mode: reject unknown/extra fields + .messages({ + "object.unknown": "Unknown field {{#label}} is not allowed in policy payload", + }); + +// ── Known regex-operator rules that need ReDoS check ── +export interface ValidationResult { + valid: boolean; + error?: string; + sanitizedName?: string; + sanitizedDescription?: string; +} + +/** + * Validate and sanitize a policy payload before storage. + * Returns either a validated result with sanitized strings, or an error. + */ +export function validateAndSanitizePolicy( + rawPayload: unknown, +): ValidationResult { + // 1. Check for prototype pollution (__proto__ / constructor keys) + // `__proto__` in a JSON payload is treated as a prototype-setter by + // JSON.parse, so we cannot detect it via hasOwnProperty or JSON.stringify. + // Instead, check that the prototype is the expected Object.prototype. + if (rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)) { + const proto = Object.getPrototypeOf(rawPayload as object); + // A normal parsed JSON object always has Object.prototype (or null for + // Object.create(null)). Any other prototype means __proto__ was injected. + if (proto !== Object.prototype && proto !== null) { + return { valid: false, error: "Prototype pollution detected: __proto__ key" }; + } + // `constructor` spread into an object literal becomes an own property. + if (Object.prototype.hasOwnProperty.call(rawPayload, "constructor")) { + return { valid: false, error: "Prototype pollution detected: constructor key" }; + } + } + + // 2. Joi validation + const { error, value } = policySchema.validate(rawPayload, { + abortEarly: false, + stripUnknown: false, + }); + + if (error) { + const details = error.details.map((d) => d.message).join("; "); + return { valid: false, error: `Policy validation failed: ${details}` }; + } + + // 3. Guard against null/undefined value from Joi validation + if (!value || typeof value !== "object") { + return { valid: false, error: "Policy validation failed: payload must be a valid object" }; + } + + // 4. ReDoS safety check for regex rules + for (let i = 0; i < value.rules.length; i++) { + const rule = value.rules[i]; + if (rule.operator === "regex") { + if (!isRegexSafe(rule.value)) { + return { + valid: false, + error: `Rule[${i}] contains an unsafe regex pattern that may cause ReDoS`, + }; + } + } + } + + // 5. Sanitize name/description + const sanitizedName = sanitizeHtml(value.name); + const sanitizedDescription = value.description + ? sanitizeHtml(value.description) + : undefined; + + return { + valid: true, + sanitizedName, + sanitizedDescription, + }; +} + +export { KNOWN_ATTRIBUTES, KNOWN_OPERATORS, KNOWN_ACTIONS };