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:
parent
36b8c02d78
commit
f117dd2fa2
3 changed files with 157 additions and 7 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
92
src/spellcheck/tests/lru-cache-security.test.ts
Normal file
92
src/spellcheck/tests/lru-cache-security.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue