@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
- // Fetch with timeout wrapper — uses AbortSignal.timeout (reliably aborts the
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 res = await fetchWithTimeout(modelsUrl, {
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 (res.ok)
116
- return res.json({ valid: true });
123
+ if (probeRes.ok)
124
+ return probeRes.json({ valid: true });
117
125
  // Auth errors - no point trying chat fallback
118
- if (res.status === 401 || res.status === 403) {
119
- return res.json({ valid: false, error: "API key unauthorized" });
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 res.json({ valid: true, method: "chat" });
146
+ return probeRes.json({ valid: true, method: "chat" });
139
147
  }
140
- return res.json({
148
+ return probeRes.json({
141
149
  valid: false,
142
150
  error: getChatErrorMessage(chatRes.status),
143
151
  method: "chat"
144
152
  });
145
153
  }
146
- return res.json({ valid: false, error: getModelsErrorMessage(res.status) });
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 res = await fetchWithTimeout(modelsUrl, {
158
+ const probeRes = await fetchWithTimeout(modelsUrl, {
151
159
  headers: { "Authorization": `Bearer ${apiKey}` },
152
160
  });
153
- if (res.ok)
154
- return res.json({ valid: true });
161
+ if (probeRes.ok)
162
+ return probeRes.json({ valid: true });
155
163
  // Auth errors - no point trying chat fallback
156
- if (res.status === 401 || res.status === 403) {
157
- return res.json({ valid: false, error: "API key unauthorized" });
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 res.json({ valid: true, method: "chat" });
182
+ return probeRes.json({ valid: true, method: "chat" });
175
183
  }
176
184
  return res.json({
177
185
  valid: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fudrouter/fsrouter",
3
- "version": "0.6.113",
3
+ "version": "0.6.115",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "9Router v2 — Express Backend (API, SSE, DB, Auth, MITM)",