feat(inventory): granular item-level validation and auto-capitalization

Enhances validation to clean corrupted items at load time while preserving
valid ones, rather than discarding entire sections. Also auto-capitalizes
first letter of items for consistency.

New capability - Granular Item Cleaning:

1. **cleanItemString()** (src/utils/security.js):
   - Parses item string, removes bad items, re-serializes clean ones
   - Applies ALL parsing rules: markdown, sanitization, length limits
   - Used at load time to clean persisted data immediately
   - Returns "None" if no valid items remain

2. **Enhanced validateStoredInventory()**:
   - Now cleans items within each location
   - Only removes locations if ALL items are invalid
   - Example: "Home": "Sword, __proto__, Shield" → "Home": "Sword, Shield"
   - Example: "Bad": "__proto__, constructor" → location removed

3. **Enhanced validateInventoryStructure()** (src/core/persistence.js):
   - Cleans onPerson, stored, and assets at load time
   - Logs exactly what was cleaned for debugging
   - Auto-saves cleaned data back to storage

Auto-Capitalization:

- Added to cleanSingleItem() in itemParser.js
- Capitalizes first letter of each item after all cleaning
- Preserves rest of case: "iPhone" → "iPhone" (not "Iphone")
- Examples: "sword" → "Sword", "3x potions" → "3x potions"

Behavior examples:

Before (threw away entire array):
- "Home": "Sword, " + "A".repeat(600) + ", Shield"
  → Entire location lost

After (granular cleaning):
- "Home": "Sword, " + "A".repeat(600) + ", Shield"
  → "Home": "Sword, AAA...(500 chars), Shield"

Before (kept corrupted data):
- onPerson: "sword, __proto__, shield"
  → Stored as-is, filtered only at render

After (cleaned at load):
- onPerson: "Sword, Shield"
  → Cleaned and saved immediately, capitalized

Benefits:
- ✓ Preserves valid items when some are corrupted
- ✓ Cleans data at source, not just at render
- ✓ Detailed logging of what was cleaned
- ✓ Consistent capitalization across all items
- ✓ Single source of truth for "valid item"
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-20 07:50:43 +11:00
parent e21e71b03a
commit dc603b8b49
3 changed files with 74 additions and 4 deletions
+17 -1
View File
@@ -15,7 +15,7 @@ import {
FEATURE_FLAGS
} from './state.js';
import { migrateInventory } from '../utils/migration.js';
import { validateStoredInventory } from '../utils/security.js';
import { validateStoredInventory, cleanItemString } from '../utils/security.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
@@ -212,6 +212,14 @@ function validateInventoryStructure(inventory, source) {
console.warn(`[RPG Companion] Invalid onPerson from ${source}, resetting to "None"`);
inventory.onPerson = "None";
needsSave = true;
} else {
// Clean items in onPerson (removes corrupted/dangerous items)
const cleanedOnPerson = cleanItemString(inventory.onPerson);
if (cleanedOnPerson !== inventory.onPerson) {
console.warn(`[RPG Companion] Cleaned corrupted items from onPerson inventory (${source})`);
inventory.onPerson = cleanedOnPerson;
needsSave = true;
}
}
// Validate stored field (CRITICAL for Bug #3)
@@ -234,6 +242,14 @@ function validateInventoryStructure(inventory, source) {
console.warn(`[RPG Companion] Invalid assets from ${source}, resetting to "None"`);
inventory.assets = "None";
needsSave = true;
} else {
// Clean items in assets (removes corrupted/dangerous items)
const cleanedAssets = cleanItemString(inventory.assets);
if (cleanedAssets !== inventory.assets) {
console.warn(`[RPG Companion] Cleaned corrupted items from assets inventory (${source})`);
inventory.assets = cleanedAssets;
needsSave = true;
}
}
// Persist repairs if needed
+7 -1
View File
@@ -187,7 +187,7 @@ export function parseItems(itemString) {
/**
* Cleans a single item string (helper for parseItems)
* Removes list markers, wrapping quotes, and trims
* Removes list markers, wrapping quotes, trims, and capitalizes first letter
*
* @param {string} item - Single item string to clean
* @returns {string|null} Cleaned item or null if empty/invalid
@@ -222,6 +222,12 @@ function cleanSingleItem(item) {
return null;
}
// Capitalize first letter for consistency
// Preserves rest of string case (e.g., "iPhone" stays "iPhone", not "Iphone")
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
return cleaned;
}
+50 -2
View File
@@ -3,6 +3,8 @@
* Handles input sanitization and validation to prevent security vulnerabilities
*/
import { parseItems, serializeItems } from './itemParser.js';
/**
* List of dangerous property names that could cause prototype pollution
* or shadow critical object methods.
@@ -99,6 +101,7 @@ export function sanitizeItemName(name) {
/**
* Validates and cleans a stored inventory object.
* Ensures all keys are safe property names and all values are strings.
* Cleans items within each location (removes corrupted/dangerous items).
* Prevents prototype pollution attacks via object keys.
*
* @param {Object} stored - Raw stored inventory object
@@ -108,9 +111,15 @@ export function sanitizeItemName(name) {
* validateStoredInventory({ "Home": "Sword, Shield" })
* // → { "Home": "Sword, Shield" }
*
* validateStoredInventory({ "Home": "Sword, __proto__, Shield" })
* // → { "Home": "Sword, Shield" } (dangerous item removed)
*
* validateStoredInventory({ "__proto__": "malicious" })
* // → {} (dangerous key removed, logged)
*
* validateStoredInventory({ "BadLocation": "__proto__, constructor" })
* // → {} (location removed because all items were invalid, logged)
*
* validateStoredInventory(null)
* // → {} (invalid input, returns empty object)
*/
@@ -143,8 +152,15 @@ export function validateStoredInventory(stored) {
continue;
}
// Add to cleaned object
cleaned[sanitizedKey] = value;
// Clean items within this location (removes corrupted/dangerous items)
const cleanedValue = cleanItemString(value);
// Only add location if it has valid items remaining
if (cleanedValue && cleanedValue !== 'None' && cleanedValue.toLowerCase() !== 'none') {
cleaned[sanitizedKey] = cleanedValue;
} else {
console.warn(`[RPG Companion] Location "${sanitizedKey}" had no valid items after cleaning, removing location`);
}
}
return cleaned;
@@ -156,3 +172,35 @@ export function validateStoredInventory(stored) {
* @constant {number}
*/
export const MAX_ITEMS_PER_SECTION = 500;
/**
* Cleans an item string by parsing and re-serializing.
* Removes corrupted, dangerous, or invalid items while preserving valid ones.
* Applies ALL parsing rules: markdown stripping, sanitization, length limits, etc.
*
* This is used at LOAD time to clean persisted data immediately, not just at render time.
*
* @param {string} itemString - Raw item string (possibly corrupted)
* @returns {string} Clean item string with only valid items, or "None" if no valid items
*
* @example
* cleanItemString("Sword, Shield") // "Sword, Shield" (unchanged)
* cleanItemString("Sword, __proto__, Shield") // "Sword, Shield" (dangerous item removed)
* cleanItemString("A".repeat(600) + ", Sword") // "AAA... (truncated), Sword"
* cleanItemString("**Sword**, *Shield*") // "Sword, Shield" (markdown stripped)
* cleanItemString("__proto__, constructor") // "None" (all items invalid)
*/
export function cleanItemString(itemString) {
// Parse using robust parser (handles all edge cases, sanitizes each item)
// This applies: newlines→commas, markdown stripping, parenthesis-aware splitting,
// sanitizeItemName() validation, length limits, max items limit
const items = parseItems(itemString);
// If no valid items remain after parsing/sanitization, return "None"
if (items.length === 0) {
return "None";
}
// Re-serialize clean items back to string format
return serializeItems(items);
}