security(spellcheck): 🔒️ Add security tests for secure fetch loader, SymSpell integration, and LRU cache validation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-27 14:15:20 -08:00
parent 36b8c02d78
commit f117dd2fa2
3 changed files with 157 additions and 7 deletions

View file

@ -31,20 +31,20 @@ describe('FetchDictionaryLoader — URL injection protection', () => {
expect(result).toBe(false);
});
it('resolves normal relative paths correctly', () => {
it('resolves normal relative paths correctly', async () => {
const loader2 = new FetchDictionaryLoader('https://cdn.example.com/dictionaries');
// loadText will fail on fetch (no server), but should NOT throw injection errors
// We test the URL resolution by expecting a fetch error, not a security error
expect(loader2.loadText('english/words.txt')).rejects.toThrow();
await expect(loader2.loadText('english/words.txt')).rejects.toThrow();
});
it('handles base URL with trailing slash consistently', () => {
it('handles base URL with trailing slash consistently', async () => {
const loaderWithSlash = new FetchDictionaryLoader('https://cdn.example.com/dict/');
const loaderNoSlash = new FetchDictionaryLoader('https://cdn.example.com/dict');
// Both should reject traversal identically
expect(loaderWithSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected');
expect(loaderNoSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected');
await expect(loaderWithSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected');
await expect(loaderNoSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected');
});
});

View file

@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { LRUCache } from '../utils/lru-cache';
describe('LRUCache — accessCounter overflow protection', () => {
it('compacts when counter approaches MAX_SAFE_INTEGER', () => {
const cache = new LRUCache<string, number>(10);
// Add some entries
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
// Force the counter near the threshold by manipulating internal state
// We access the private field via bracket notation for testing
const cacheAny = cache as unknown as {
accessCounter: number;
accessOrder: Map<string, number>;
};
cacheAny.accessCounter = Number.MAX_SAFE_INTEGER - 500_000;
// Set access orders to values near the threshold
cacheAny.accessOrder.set('a', Number.MAX_SAFE_INTEGER - 500_003);
cacheAny.accessOrder.set('b', Number.MAX_SAFE_INTEGER - 500_002);
cacheAny.accessOrder.set('c', Number.MAX_SAFE_INTEGER - 500_001);
// This get() should trigger compaction since counter is near threshold
cache.get('a');
// After compaction, counter should be reset to a small value
expect(cacheAny.accessCounter).toBeLessThan(100);
// Eviction order should still be correct: b was least recently used
// (a was just accessed, c was accessed before a but after b)
cache.set('d', 4);
cache.set('e', 5);
cache.set('f', 6);
cache.set('g', 7);
cache.set('h', 8);
cache.set('i', 9);
cache.set('j', 10);
// Cache is now full (10 items: a,b,c,d,e,f,g,h,i,j)
// Adding one more should evict the LRU item (b, which wasn't accessed)
cache.set('k', 11);
expect(cache.has('b')).toBe(false); // b was evicted (LRU)
expect(cache.has('a')).toBe(true); // a was recently accessed
expect(cache.has('k')).toBe(true); // k was just added
});
it('preserves correct eviction order after compaction', () => {
const cache = new LRUCache<string, number>(3);
cache.set('x', 1);
cache.set('y', 2);
cache.set('z', 3);
// Access in order: x, y (so z is LRU)
cache.get('x');
cache.get('y');
// Force compaction
const cacheAny = cache as unknown as { accessCounter: number };
cacheAny.accessCounter = Number.MAX_SAFE_INTEGER - 500_000;
// Trigger compaction via a set
cache.set('z', 33); // updates z's access time, triggers compaction
// Now x is LRU (accessed before y, and z was just updated)
cache.set('w', 4); // should evict x
expect(cache.has('x')).toBe(false);
expect(cache.has('y')).toBe(true);
expect(cache.has('z')).toBe(true);
expect(cache.has('w')).toBe(true);
});
it('does not compact when counter is well below threshold', () => {
const cache = new LRUCache<string, number>(5);
cache.set('a', 1);
cache.set('b', 2);
cache.get('a');
cache.get('b');
const cacheAny = cache as unknown as { accessCounter: number };
// Counter should be small and not compacted
expect(cacheAny.accessCounter).toBeLessThan(100);
});
});

View file

@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SpellChecker } from '../spell-checker.js';
import type { SpellEngine, SpellSuggestion } from '../engines/types.js';
import { SpellChecker } from '../spell-checker';
import { SymSpellEngine } from '../engines/symspell-engine';
import type { SpellEngine, SpellSuggestion } from '../engines/types';
/**
* Mock SpellEngine that simulates SymSpell behavior:
@ -575,3 +576,60 @@ describe('SpellEngine interface edge cases', () => {
});
});
});
describe('SymSpellEngine — maxEditDistance validation', () => {
it('accepts maxEditDistance of 0', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
maxEditDistance: 0,
})).not.toThrow();
});
it('accepts maxEditDistance of 5', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
maxEditDistance: 5,
})).not.toThrow();
});
it('accepts default maxEditDistance (undefined → 2)', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
})).not.toThrow();
});
it('rejects maxEditDistance of 6', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
maxEditDistance: 6,
})).toThrow('maxEditDistance must be an integer between 0 and 5, got 6');
});
it('rejects maxEditDistance of 100', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
maxEditDistance: 100,
})).toThrow('maxEditDistance must be an integer between 0 and 5');
});
it('rejects negative maxEditDistance', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
maxEditDistance: -1,
})).toThrow('maxEditDistance must be an integer between 0 and 5, got -1');
});
it('rejects fractional maxEditDistance', () => {
expect(() => new SymSpellEngine({
wasmUrl: 'file:///dummy.wasm',
dictionaryUrl: 'file:///dummy.txt',
maxEditDistance: 2.5,
})).toThrow('maxEditDistance must be an integer between 0 and 5, got 2.5');
});
});