diff --git a/api/prisma/migrations/20250920152804_add_goal_meta_field/migration.sql b/api/prisma/migrations/20250920152804_add_goal_meta_field/migration.sql new file mode 100644 index 00000000..38619190 --- /dev/null +++ b/api/prisma/migrations/20250920152804_add_goal_meta_field/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Goal" ADD COLUMN "meta" JSONB; + +-- Add constraint to limit meta JSON size to 1024 characters +ALTER TABLE "Goal" ADD CONSTRAINT "Goal_meta_size_check" +CHECK ("meta" IS NULL OR LENGTH("meta"::text) <= 4096); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9ddf7451..9f95543d 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -17,6 +17,7 @@ model Goal { oldCategories String[] categories Category[] @relation("GoalCategories") difficulty Int? + meta Json? game Game @relation(fields: [gameId], references: [id]) variants GoalVariant[] createdAt DateTime @default(now()) diff --git a/api/src/routes/goals/Goals.ts b/api/src/routes/goals/Goals.ts index d62d802d..36ee191e 100644 --- a/api/src/routes/goals/Goals.ts +++ b/api/src/routes/goals/Goals.ts @@ -4,6 +4,7 @@ import { deleteGoal, editGoal, gameForGoal } from '../../database/games/Goals'; import upload from './Upload'; import goalCategories from './GoalCategories'; import { Prisma } from '@prisma/client'; +import { validateGoalMeta } from '../../util/GoalValidation'; const goals = Router(); @@ -31,16 +32,26 @@ goals.post('/:id', async (req, res) => { } const { id } = req.params; - const { goal, description, categories, difficulty } = req.body; + const { goal, description, categories, difficulty, meta } = req.body; - if (!goal && description === undefined && !categories && !difficulty) { + if (!goal && description === undefined && !categories && !difficulty && meta === undefined) { res.status(400).send('No changes submitted'); return; } + // Validate meta data if provided + if (meta !== undefined) { + const metaValidation = validateGoalMeta(meta); + if (!metaValidation.valid) { + res.status(400).json({ error: metaValidation.error }); + return; + } + } + const input: Prisma.GoalUpdateInput = { goal, description, + meta, }; if (difficulty === 0) { diff --git a/api/src/tests/GoalValidation.test.ts b/api/src/tests/GoalValidation.test.ts new file mode 100644 index 00000000..9b1c13e8 --- /dev/null +++ b/api/src/tests/GoalValidation.test.ts @@ -0,0 +1,189 @@ +import { validateGoalMeta } from '../util/GoalValidation'; + +describe('GoalValidation', () => { + describe('validateGoalMeta', () => { + it('should allow null and undefined values', () => { + expect(validateGoalMeta(null)).toEqual({ valid: true }); + expect(validateGoalMeta(undefined as any)).toEqual({ valid: true }); + }); + + it('should allow simple valid objects', () => { + const validMeta = { + type: 'auto-track', + config: { enabled: true }, + tags: ['important', 'urgent'] + }; + expect(validateGoalMeta(validMeta)).toEqual({ valid: true }); + }); + + it('should reject objects that exceed byte size limit', () => { + // Create a large string that exceeds 4096 bytes + const largeString = 'x'.repeat(5000); + const largeMeta = { data: largeString }; + + const result = validateGoalMeta(largeMeta); + expect(result.valid).toBe(false); + expect(result.error).toContain('exceeds maximum size of 4096 bytes'); + }); + + it('should allow deeply nested objects under byte limit', () => { + // Create a deeply nested object that's still under byte limit + function makeDeepObject(depth: number) { + let obj: any = {}; + let cur = obj; + for (let i = 0; i < depth; i++) { + cur.a = {}; + cur = cur.a; + } + return obj; + } + + const deepObject = makeDeepObject(10); // Deep but small + const result = validateGoalMeta(deepObject); + + expect(result.valid).toBe(true); + }); + + it('should reject objects with dangerous prototype pollution keys', () => { + // Create object with dangerous key using Object.defineProperty to bypass JS filtering + const dangerousMeta: any = { normal: 'value' }; + Object.defineProperty(dangerousMeta, '__proto__', { + value: { isAdmin: true }, + enumerable: true, + configurable: true + }); + + const result = validateGoalMeta(dangerousMeta); + expect(result.valid).toBe(false); + expect(result.error).toContain('contains forbidden key "__proto__"'); + }); + + it('should reject objects with prototype key', () => { + const dangerousMeta = { + prototype: { isAdmin: true }, + normal: 'value' + }; + + const result = validateGoalMeta(dangerousMeta); + expect(result.valid).toBe(false); + expect(result.error).toContain('contains forbidden key "prototype"'); + }); + + it('should reject objects with constructor key', () => { + const dangerousMeta = { + constructor: { isAdmin: true }, + normal: 'value' + }; + + const result = validateGoalMeta(dangerousMeta); + expect(result.valid).toBe(false); + expect(result.error).toContain('contains forbidden key "constructor"'); + }); + + it('should reject objects with toString key', () => { + const dangerousMeta = { + toString: 'hacked', + normal: 'value' + }; + + const result = validateGoalMeta(dangerousMeta as any); + expect(result.valid).toBe(false); + expect(result.error).toContain('contains forbidden key "toString"'); + }); + + it('should reject objects with valueOf key', () => { + const dangerousMeta = { + valueOf: 'hacked', + normal: 'value' + }; + + const result = validateGoalMeta(dangerousMeta as any); + expect(result.valid).toBe(false); + expect(result.error).toContain('contains forbidden key "valueOf"'); + }); + + it('should handle arrays correctly', () => { + const arrayMeta = { + items: [1, 2, 3, { nested: true }], + count: 4 + }; + + expect(validateGoalMeta(arrayMeta)).toEqual({ valid: true }); + }); + + it('should handle deeply nested arrays', () => { + const deepArray = [[[[[['deep']]]]]]; // 6 levels deep + const result = validateGoalMeta(deepArray); + + expect(result.valid).toBe(true); // No depth limit, only byte limit + }); + + it('should reject non-JSON-serializable data', () => { + // Create truly non-serializable data with circular reference + const nonSerializable: any = { + normal: 'value' + }; + nonSerializable.self = nonSerializable; // Circular reference + + const result = validateGoalMeta(nonSerializable); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid JSON data for goal meta'); + }); + + it('should handle circular references gracefully', () => { + const circular: any = { name: 'test' }; + circular.self = circular; + + const result = validateGoalMeta(circular); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid JSON data for goal meta'); + }); + }); + + // Removed analyzeGoalMeta tests - function no longer exists + + describe('Depth Bomb Attack Simulation', () => { + it('should handle depth bomb with recursive traversal (safe for 4KB payloads)', () => { + // Create a depth bomb that would crash recursive implementations + function makeDepthBomb(depth: number) { + let obj: any = {}; + let cur = obj; + for (let i = 0; i < depth; i++) { + cur.a = {}; + cur = cur.a; + } + return obj; + } + + // Test with a depth that would cause stack overflow in recursive implementations + const depthBomb = makeDepthBomb(500); // Deep but under byte limit + const json = JSON.stringify(depthBomb); + + // Verify it's under byte limit + expect(Buffer.byteLength(json, 'utf8')).toBeLessThan(4096); + + // Our recursive implementation should handle this gracefully for 4KB payloads + const result = validateGoalMeta(depthBomb); + expect(result.valid).toBe(true); // Valid because under byte limit + }); + + it('should handle wide but shallow objects', () => { + // Create an object with many properties but shallow depth + const wideObject: any = {}; + for (let i = 0; i < 100; i++) { + wideObject[`prop${i}`] = `value${i}`; + } + + // Should be valid (no property count limit, shallow depth) + expect(validateGoalMeta(wideObject)).toEqual({ valid: true }); + }); + + it('should handle large arrays', () => { + // Create a large array (no array length limit) but keep it under byte limit + const largeArray = new Array(100).fill(0).map((_, i) => ({ id: i, value: `item${i}` })); + + // Should be valid (no array length limit) + expect(validateGoalMeta(largeArray)).toEqual({ valid: true }); + }); + }); +}); diff --git a/api/src/util/GoalValidation.ts b/api/src/util/GoalValidation.ts new file mode 100644 index 00000000..ecb741b2 --- /dev/null +++ b/api/src/util/GoalValidation.ts @@ -0,0 +1,81 @@ +import { Prisma } from '@prisma/client'; + +const MAX_BYTES = 4096; // measure bytes, not string length + +export interface GoalMetaValidationResult { + valid: boolean; + error?: string; +} + +const DANGEROUS_KEYS = new Set([ + '__proto__', // Direct prototype access + 'prototype', // Constructor prototype + 'constructor', // Constructor function + 'toString', // Could override object stringification + 'valueOf' // Could override object value conversion +]); + +/** + * Check for dangerous keys in the meta object. + * Simple recursive check - safe for 4KB payloads. + */ +function checkDangerousKeys(meta: Prisma.JsonValue): string | null { + if (meta === null || meta === undefined || typeof meta !== 'object') { + return null; + } + + if (Array.isArray(meta)) { + // Check array elements + for (const item of meta) { + const result = checkDangerousKeys(item); + if (result) return result; + } + } else { + // Check object keys + for (const [key, value] of Object.entries(meta) as [string, Prisma.JsonValue][]) { + if (DANGEROUS_KEYS.has(key)) { + return `Goal meta contains forbidden key "${key}"`; + } + // Recursively check nested values + const result = checkDangerousKeys(value); + if (result) return result; + } + } + + return null; +} + +/** + * Validate meta with all guards and early exits. + */ +export function validateGoalMeta(meta: Prisma.JsonValue): GoalMetaValidationResult { + // Allow null/undefined values + if (meta === null || meta === undefined) { + return { valid: true }; + } + + try { + // Convert to JSON and measure bytes (UTF-8) + const json = JSON.stringify(meta); // also ensures it's JSON-serializable + const bytes = Buffer.byteLength(json, 'utf8'); + + if (bytes > MAX_BYTES) { + return { + valid: false, + error: `Goal meta exceeds maximum size of ${MAX_BYTES} bytes (got ${bytes})`, + }; + } + + const dangerousKeyError = checkDangerousKeys(meta); + if (dangerousKeyError) { + return { valid: false, error: dangerousKeyError }; + } + + return { valid: true }; + } catch (error) { + return { + valid: false, + error: `Invalid JSON data for goal meta: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } +} \ No newline at end of file diff --git a/schema/schemas/Goal.json b/schema/schemas/Goal.json index 3f9a2033..8ade8419 100644 --- a/schema/schemas/Goal.json +++ b/schema/schemas/Goal.json @@ -9,6 +9,11 @@ "goal": {"type": "string"}, "description": {"type": ["string", "null"]}, "difficulty": {"type": ["number", "null"]}, - "categories": {"type": "array", "items": {"type": "string"}} + "categories": {"type": "array", "items": {"type": "string"}}, + "meta": { + "type": ["object", "null"], + "description": "Optional metadata for goal auto-tracking and additional properties", + "additionalProperties": true + } } } \ No newline at end of file