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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user