Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
15 changes: 13 additions & 2 deletions api/src/routes/goals/Goals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down
189 changes: 189 additions & 0 deletions api/src/tests/GoalValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
81 changes: 81 additions & 0 deletions api/src/util/GoalValidation.ts
Original file line number Diff line number Diff line change
@@ -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'}`,
};
}
}
7 changes: 6 additions & 1 deletion schema/schemas/Goal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}