@fudrouter/fsrouter 0.6.113 → 0.6.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SSRF guard: block internal/private/metadata targets for server-side fetch.
|
|
2
|
+
// Mirrors 9router's assertPublicUrl so custom provider nodes can't be pointed
|
|
3
|
+
// at localhost / cloud metadata endpoints (e.g. 169.254.169.254).
|
|
4
|
+
|
|
5
|
+
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
|
|
6
|
+
const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"];
|
|
7
|
+
|
|
8
|
+
function ipv4ToInt(host) {
|
|
9
|
+
const parts = host.split(".");
|
|
10
|
+
if (parts.length !== 4) return null;
|
|
11
|
+
let value = 0;
|
|
12
|
+
for (const part of parts) {
|
|
13
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
14
|
+
const octet = Number(part);
|
|
15
|
+
if (octet > 255) return null;
|
|
16
|
+
value = value * 256 + octet;
|
|
17
|
+
}
|
|
18
|
+
return value >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BLOCKED_V4_RANGES = [
|
|
22
|
+
[ipv4ToInt("0.0.0.0"), 8],
|
|
23
|
+
[ipv4ToInt("10.0.0.0"), 8],
|
|
24
|
+
[ipv4ToInt("127.0.0.0"), 8],
|
|
25
|
+
[ipv4ToInt("169.254.0.0"), 16],
|
|
26
|
+
[ipv4ToInt("172.16.0.0"), 12],
|
|
27
|
+
[ipv4ToInt("192.168.0.0"), 16],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function isBlockedIpv4(host) {
|
|
31
|
+
const ip = ipv4ToInt(host);
|
|
32
|
+
if (ip === null) return false;
|
|
33
|
+
return BLOCKED_V4_RANGES.some(([base, bits]) => {
|
|
34
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
35
|
+
return (ip & mask) === (base & mask);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isBlockedIpv6(host) {
|
|
40
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
41
|
+
const v4Mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
42
|
+
if (v4Mapped) return isBlockedIpv4(v4Mapped[1]);
|
|
43
|
+
if (h === "::1" || h === "::") return true;
|
|
44
|
+
return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Throw if URL targets a non-public host. Caller should map to 400.
|
|
48
|
+
export function assertPublicUrl(rawUrl) {
|
|
49
|
+
const parsed = new URL(rawUrl);
|
|
50
|
+
const host = parsed.hostname.toLowerCase();
|
|
51
|
+
if (BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked host");
|
|
52
|
+
if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked host suffix");
|
|
53
|
+
if (isBlockedIpv4(host) || isBlockedIpv6(host)) throw new Error("Blocked private IP");
|
|
54
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { assertPublicUrl } from "../../../lib/ssrfGuard.js";
|
|
2
|
+
// Fetch with timeout wrapper — uses AbortController (reliably aborts the
|
|
2
3
|
// underlying request instead of just racing a dangling promise that can hang
|
|
3
4
|
// the whole HTTP handler / make the browser show "Network error").
|
|
4
5
|
const fetchWithTimeout = (url, options, timeout = 10000) => {
|
|
@@ -68,6 +69,13 @@ export async function POST_handler(req, res) {
|
|
|
68
69
|
if (!isValidUrl(baseUrl)) {
|
|
69
70
|
return res.status(400).json({ error: "Invalid URL format" });
|
|
70
71
|
}
|
|
72
|
+
// SSRF guard: block localhost / private / cloud-metadata targets
|
|
73
|
+
try {
|
|
74
|
+
assertPublicUrl(baseUrl);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return res.status(400).json({ error: "URL not allowed (internal/private address)" });
|
|
78
|
+
}
|
|
71
79
|
// Custom Embedding Validation - test POST /embeddings directly
|
|
72
80
|
if (type === "custom-embedding") {
|
|
73
81
|
const normalizedBase = baseUrl.trim().replace(/\/$/, "");
|
|
@@ -104,7 +112,7 @@ export async function POST_handler(req, res) {
|
|
|
104
112
|
normalizedBase = normalizedBase.slice(0, -9);
|
|
105
113
|
}
|
|
106
114
|
const modelsUrl = `${normalizedBase}/models`;
|
|
107
|
-
const
|
|
115
|
+
const probeRes = await fetchWithTimeout(modelsUrl, {
|
|
108
116
|
method: "GET",
|
|
109
117
|
headers: {
|
|
110
118
|
"x-api-key": apiKey,
|
|
@@ -112,11 +120,11 @@ export async function POST_handler(req, res) {
|
|
|
112
120
|
"Authorization": `Bearer ${apiKey}`
|
|
113
121
|
}
|
|
114
122
|
});
|
|
115
|
-
if (
|
|
116
|
-
return
|
|
123
|
+
if (probeRes.ok)
|
|
124
|
+
return probeRes.json({ valid: true });
|
|
117
125
|
// Auth errors - no point trying chat fallback
|
|
118
|
-
if (
|
|
119
|
-
return
|
|
126
|
+
if (probeRes.status === 401 || probeRes.status === 403) {
|
|
127
|
+
return probeRes.json({ valid: false, error: "API key unauthorized" });
|
|
120
128
|
}
|
|
121
129
|
// Fallback: try chat/completions if modelId provided
|
|
122
130
|
if (modelId) {
|
|
@@ -135,26 +143,26 @@ export async function POST_handler(req, res) {
|
|
|
135
143
|
})
|
|
136
144
|
});
|
|
137
145
|
if (chatRes.ok) {
|
|
138
|
-
return
|
|
146
|
+
return probeRes.json({ valid: true, method: "chat" });
|
|
139
147
|
}
|
|
140
|
-
return
|
|
148
|
+
return probeRes.json({
|
|
141
149
|
valid: false,
|
|
142
150
|
error: getChatErrorMessage(chatRes.status),
|
|
143
151
|
method: "chat"
|
|
144
152
|
});
|
|
145
153
|
}
|
|
146
|
-
return
|
|
154
|
+
return probeRes.json({ valid: false, error: getModelsErrorMessage(probeRes.status) });
|
|
147
155
|
}
|
|
148
156
|
// OpenAI Compatible Validation (Default)
|
|
149
157
|
const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
|
|
150
|
-
const
|
|
158
|
+
const probeRes = await fetchWithTimeout(modelsUrl, {
|
|
151
159
|
headers: { "Authorization": `Bearer ${apiKey}` },
|
|
152
160
|
});
|
|
153
|
-
if (
|
|
154
|
-
return
|
|
161
|
+
if (probeRes.ok)
|
|
162
|
+
return probeRes.json({ valid: true });
|
|
155
163
|
// Auth errors - no point trying chat fallback
|
|
156
|
-
if (
|
|
157
|
-
return
|
|
164
|
+
if (probeRes.status === 401 || probeRes.status === 403) {
|
|
165
|
+
return probeRes.json({ valid: false, error: "API key unauthorized" });
|
|
158
166
|
}
|
|
159
167
|
// Fallback: try chat/completions if modelId provided
|
|
160
168
|
if (modelId) {
|
|
@@ -171,7 +179,7 @@ export async function POST_handler(req, res) {
|
|
|
171
179
|
})
|
|
172
180
|
});
|
|
173
181
|
if (chatRes.ok) {
|
|
174
|
-
return
|
|
182
|
+
return probeRes.json({ valid: true, method: "chat" });
|
|
175
183
|
}
|
|
176
184
|
return res.json({
|
|
177
185
|
valid: false,
|