feat(inventory): add security hardening for prototype pollution and DoS

Created comprehensive security layer to protect against malicious input
and resource exhaustion attacks.

New security.js module:
- sanitizeLocationName(): Blocks __proto__, constructor, toString, etc.
- sanitizeItemName(): Enforces max length (500 chars)
- validateStoredInventory(): Validates entire stored object structure
- MAX_ITEMS_PER_SECTION: Limit of 500 items per section

Protected attack vectors:
1. Prototype pollution via location names
   - Blocked: "__proto__", "constructor", "prototype", etc.
   - Alert shown to user if attempted

2. DoS via extremely long names
   - Location names: max 200 chars (truncated with warning)
   - Item names: max 500 chars (truncated with warning)

3. DoS via massive item lists
   - Max 500 items per section (truncated with warning)

Integration:
- itemParser.js: Uses sanitizeItemName() and enforces max items
- inventoryActions.js: Validates all user input before saving
  - Manual location creation: blocked dangerous names
  - Manual item addition: length limits enforced

Security best practices (2025):
- No regex DoS vulnerabilities (character-by-character parsing)
- Explicit hasOwnProperty checks to avoid inherited properties
- Console warnings for all security events (auditing)
- Graceful degradation (truncate, don't crash)
- Defense in depth (validation at multiple layers)

This protects against both malicious actors and accidental abuse.
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-20 07:16:54 +11:00
parent 3a84e24c0a
commit 681c2f0e47
3 changed files with 196 additions and 5 deletions
+19 -2
View File
@@ -8,6 +8,7 @@ import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/p
import { buildInventorySummary } from '../generation/promptBuilder.js'; import { buildInventorySummary } from '../generation/promptBuilder.js';
import { renderInventory } from '../rendering/inventory.js'; import { renderInventory } from '../rendering/inventory.js';
import { parseItems, serializeItems } from '../../utils/itemParser.js'; import { parseItems, serializeItems } from '../../utils/itemParser.js';
import { sanitizeLocationName, sanitizeItemName } from '../../utils/security.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -139,9 +140,17 @@ export function saveAddItem(field, location) {
} }
const input = $(inputId); const input = $(inputId);
const itemName = input.val().trim(); const rawItemName = input.val().trim();
if (!rawItemName) {
hideAddItemForm(field, location);
return;
}
// Security: Validate and sanitize item name
const itemName = sanitizeItemName(rawItemName);
if (!itemName) { if (!itemName) {
alert('Invalid item name.');
hideAddItemForm(field, location); hideAddItemForm(field, location);
return; return;
} }
@@ -246,9 +255,17 @@ export function hideAddLocationForm() {
export function saveAddLocation() { export function saveAddLocation() {
const inventory = extensionSettings.userStats.inventory; const inventory = extensionSettings.userStats.inventory;
const input = $('#rpg-new-location-name'); const input = $('#rpg-new-location-name');
const locationName = input.val().trim(); const rawLocationName = input.val().trim();
if (!rawLocationName) {
hideAddLocationForm();
return;
}
// Security: Validate and sanitize location name
const locationName = sanitizeLocationName(rawLocationName);
if (!locationName) { if (!locationName) {
alert('Invalid location name. Avoid special names like "__proto__" or "constructor".');
hideAddLocationForm(); hideAddLocationForm();
return; return;
} }
+19 -3
View File
@@ -3,6 +3,8 @@
* Utilities for parsing item strings into arrays and vice versa * Utilities for parsing item strings into arrays and vice versa
*/ */
import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js';
/** /**
* Parses item strings from AI responses into clean arrays. * Parses item strings from AI responses into clean arrays.
* Handles numerous AI formatting quirks and edge cases. * Handles numerous AI formatting quirks and edge cases.
@@ -124,7 +126,7 @@ export function parseItems(itemString) {
processed = processed.replace(/\s+/g, ' '); processed = processed.replace(/\s+/g, ' ');
// STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses) // STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses)
// Also handles list markers and quotes per-item // Also handles list markers, quotes, and security validation per-item
const items = []; const items = [];
let currentItem = ''; let currentItem = '';
parenDepth = 0; parenDepth = 0;
@@ -147,7 +149,17 @@ export function parseItems(itemString) {
// Comma outside parentheses - this is a separator // Comma outside parentheses - this is a separator
const cleaned = cleanSingleItem(currentItem); const cleaned = cleanSingleItem(currentItem);
if (cleaned) { if (cleaned) {
items.push(cleaned); // Security check: validate and sanitize item name
const sanitized = sanitizeItemName(cleaned);
if (sanitized) {
items.push(sanitized);
}
// DoS protection: enforce max items limit
if (items.length >= MAX_ITEMS_PER_SECTION) {
console.warn(`[RPG Companion] Reached max items limit (${MAX_ITEMS_PER_SECTION}), truncating list`);
return items;
}
} }
currentItem = ''; // Start new item currentItem = ''; // Start new item
} else { } else {
@@ -158,7 +170,11 @@ export function parseItems(itemString) {
// Don't forget the last item // Don't forget the last item
const cleaned = cleanSingleItem(currentItem); const cleaned = cleanSingleItem(currentItem);
if (cleaned) { if (cleaned) {
items.push(cleaned); // Security check: validate and sanitize item name
const sanitized = sanitizeItemName(cleaned);
if (sanitized) {
items.push(sanitized);
}
} }
// Warn if parentheses were unmatched // Warn if parentheses were unmatched
+158
View File
@@ -0,0 +1,158 @@
/**
* Security Utilities Module
* Handles input sanitization and validation to prevent security vulnerabilities
*/
/**
* List of dangerous property names that could cause prototype pollution
* or shadow critical object methods.
* @private
*/
const BLOCKED_PROPERTY_NAMES = [
'__proto__',
'constructor',
'prototype',
'toString',
'valueOf',
'hasOwnProperty',
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__'
];
/**
* Validates and sanitizes storage location names.
* Prevents prototype pollution and object property shadowing attacks.
*
* @param {string} name - Location name to validate
* @returns {string|null} Sanitized location name or null if invalid/dangerous
*
* @example
* sanitizeLocationName("Home") // "Home"
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
*/
export function sanitizeLocationName(name) {
if (!name || typeof name !== 'string') {
return null;
}
const trimmed = name.trim();
// Empty check
if (trimmed === '') {
return null;
}
// Check for dangerous property names (case-insensitive)
const lowerName = trimmed.toLowerCase();
if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) {
console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`);
return null;
}
// Max length check (reasonable location name)
const MAX_LOCATION_LENGTH = 200;
if (trimmed.length > MAX_LOCATION_LENGTH) {
console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`);
return trimmed.slice(0, MAX_LOCATION_LENGTH);
}
return trimmed;
}
/**
* Validates and sanitizes item names.
* Prevents excessively long item names that could cause DoS or UI issues.
*
* @param {string} name - Item name to validate
* @returns {string|null} Sanitized item name or null if invalid
*
* @example
* sanitizeItemName("Sword") // "Sword"
* sanitizeItemName("") // null
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
*/
export function sanitizeItemName(name) {
if (!name || typeof name !== 'string') {
return null;
}
const trimmed = name.trim();
// Empty check
if (trimmed === '' || trimmed.toLowerCase() === 'none') {
return null;
}
// Max length check (reasonable item name with description)
const MAX_ITEM_LENGTH = 500;
if (trimmed.length > MAX_ITEM_LENGTH) {
console.warn(`[RPG Companion] Item name too long (${trimmed.length} chars), truncating to ${MAX_ITEM_LENGTH}`);
return trimmed.slice(0, MAX_ITEM_LENGTH);
}
return trimmed;
}
/**
* Validates and cleans a stored inventory object.
* Ensures all keys are safe property names and all values are strings.
* Prevents prototype pollution attacks via object keys.
*
* @param {Object} stored - Raw stored inventory object
* @returns {Object} Cleaned stored inventory object (always a plain object)
*
* @example
* validateStoredInventory({ "Home": "Sword, Shield" })
* // → { "Home": "Sword, Shield" }
*
* validateStoredInventory({ "__proto__": "malicious" })
* // → {} (dangerous key removed, logged)
*
* validateStoredInventory(null)
* // → {} (invalid input, returns empty object)
*/
export function validateStoredInventory(stored) {
// Handle invalid input
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
return {};
}
const cleaned = {};
// Validate each property
for (const key in stored) {
// Only check own properties (not inherited)
if (!Object.prototype.hasOwnProperty.call(stored, key)) {
continue;
}
// Sanitize the location name
const sanitizedKey = sanitizeLocationName(key);
if (!sanitizedKey) {
// Key was invalid or dangerous, skip it
continue;
}
// Ensure value is a string
const value = stored[key];
if (typeof value !== 'string') {
console.warn(`[RPG Companion] Invalid stored inventory value for location "${sanitizedKey}", skipping`);
continue;
}
// Add to cleaned object
cleaned[sanitizedKey] = value;
}
return cleaned;
}
/**
* Maximum number of items allowed in a single inventory section.
* Prevents DoS via extremely large item lists.
* @constant {number}
*/
export const MAX_ITEMS_PER_SECTION = 500;