Skip to content

Commit 44d4788

Browse files
dzianisvclaude
andcommitted
fix: correct knowledge cache keying by URL and fix SSRF private IP ranges
- knowledge.ts: cache was keyed globally (single null check), so a second call with a different URL would silently return the first URL's data. Changed to a Map<url, chunks> so each URL is cached independently. - fetch.ts: SSRF block checked hostname.startsWith('172.') which blocks all 172.x addresses. Corrected to RFC1918 range 172.16–31.x.x. Also added 169.254.x.x (link-local / cloud metadata endpoint). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JKGMRpgihA4io2frodqLjt
1 parent 6e3e7c9 commit 44d4788

2 files changed

Lines changed: 35 additions & 12 deletions

File tree

packages/backend/src/routes/fetch.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ const fetchSchema = z.object({
1414
body: z.any().optional(),
1515
});
1616

17+
function isPrivateHostname(hostname: string): boolean {
18+
// Loopback
19+
if (['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname)) return true;
20+
// Link-local / cloud metadata (169.254.x.x)
21+
if (hostname.startsWith('169.254.')) return true;
22+
// RFC1918 class A (10.x.x.x)
23+
if (hostname.startsWith('10.')) return true;
24+
// RFC1918 class B (172.16.x.x – 172.31.x.x)
25+
const parts = hostname.split('.');
26+
if (parts[0] === '172' && parts.length >= 2) {
27+
const second = parseInt(parts[1], 10);
28+
if (second >= 16 && second <= 31) return true;
29+
}
30+
// RFC1918 class C (192.168.x.x)
31+
if (hostname.startsWith('192.168.')) return true;
32+
return false;
33+
}
34+
1735
fetchRoute.post('/', async (c) => {
1836
const parsed = fetchSchema.safeParse(await c.req.json());
1937
if (!parsed.success) {
@@ -26,8 +44,7 @@ fetchRoute.post('/', async (c) => {
2644
try {
2745
const urlObj = new URL(url);
2846
const hostname = urlObj.hostname;
29-
if (['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname) ||
30-
hostname.startsWith('10.') || hostname.startsWith('192.168.') || hostname.startsWith('172.')) {
47+
if (isPrivateHostname(hostname)) {
3148
return c.json({ error: 'Internal URLs are not allowed' }, 403);
3249
}
3350
} catch {
@@ -51,10 +68,10 @@ fetchRoute.post('/', async (c) => {
5168
// Read response as text (safe for JSON and text content types)
5269
const text = await response.text();
5370
if (text.length > MAX_RESPONSE_SIZE) {
54-
return c.json({
55-
error: 'Response too large',
71+
return c.json({
72+
error: 'Response too large',
5673
truncated: text.slice(0, MAX_RESPONSE_SIZE),
57-
maxSize: MAX_RESPONSE_SIZE
74+
maxSize: MAX_RESPONSE_SIZE
5875
}, 413);
5976
}
6077

packages/widget/src/agent/knowledge.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@ interface KnowledgeChunk {
77
metadata?: Record<string, any>;
88
}
99

10-
let knowledgeCache: KnowledgeChunk[] | null = null;
10+
const knowledgeCache = new Map<string, KnowledgeChunk[]>();
1111

1212
export async function loadKnowledge(url: string): Promise<KnowledgeChunk[]> {
13-
if (knowledgeCache) return knowledgeCache;
14-
13+
const cached = knowledgeCache.get(url);
14+
if (cached) return cached;
15+
1516
try {
1617
const res = await fetch(url);
1718
if (!res.ok) throw new Error(`Failed to load KB: ${res.status}`);
18-
knowledgeCache = await res.json();
19-
return knowledgeCache!;
19+
const chunks: KnowledgeChunk[] = await res.json();
20+
knowledgeCache.set(url, chunks);
21+
return chunks;
2022
} catch (err) {
2123
console.warn('Failed to load knowledge base:', err);
2224
return [];
@@ -77,6 +79,10 @@ function tokenize(text: string): string[] {
7779
.filter(t => t.length > 2);
7880
}
7981

80-
export function clearKnowledgeCache() {
81-
knowledgeCache = null;
82+
export function clearKnowledgeCache(url?: string) {
83+
if (url) {
84+
knowledgeCache.delete(url);
85+
} else {
86+
knowledgeCache.clear();
87+
}
8288
}

0 commit comments

Comments
 (0)