icebear0828 Claude Opus 4.6 commited on
Commit
deae086
·
1 Parent(s): 4ebb914

fix: proxy pool review fixes — security, validation, robustness

Browse files

- Skip disabled proxies in healthCheckAll (avoid unnecessary network calls)
- Validate proxy URL scheme (only http/https/socks5/socks5h allowed)
- Deduplicate proxy URLs on add (return existing ID if URL already exists)
- Mask proxy credentials server-side via getAllMasked() (remove client-side maskUrl)
- Add try-catch to all use-proxies hook operations (prevent unhandled rejections)
- Fix misleading route ordering comment in proxies.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

shared/hooks/use-proxies.ts CHANGED
@@ -82,24 +82,36 @@ export function useProxies(): ProxiesState {
82
 
83
  const checkProxy = useCallback(
84
  async (id: string) => {
85
- await fetch(`/api/proxies/${encodeURIComponent(id)}/check`, {
86
- method: "POST",
87
- });
 
 
 
 
88
  await refresh();
89
  },
90
  [refresh],
91
  );
92
 
93
  const checkAll = useCallback(async () => {
94
- await fetch("/api/proxies/check-all", { method: "POST" });
 
 
 
 
95
  await refresh();
96
  }, [refresh]);
97
 
98
  const enableProxy = useCallback(
99
  async (id: string) => {
100
- await fetch(`/api/proxies/${encodeURIComponent(id)}/enable`, {
101
- method: "POST",
102
- });
 
 
 
 
103
  await refresh();
104
  },
105
  [refresh],
@@ -107,9 +119,13 @@ export function useProxies(): ProxiesState {
107
 
108
  const disableProxy = useCallback(
109
  async (id: string) => {
110
- await fetch(`/api/proxies/${encodeURIComponent(id)}/disable`, {
111
- method: "POST",
112
- });
 
 
 
 
113
  await refresh();
114
  },
115
  [refresh],
@@ -117,11 +133,15 @@ export function useProxies(): ProxiesState {
117
 
118
  const assignProxy = useCallback(
119
  async (accountId: string, proxyId: string) => {
120
- await fetch("/api/proxies/assign", {
121
- method: "POST",
122
- headers: { "Content-Type": "application/json" },
123
- body: JSON.stringify({ accountId, proxyId }),
124
- });
 
 
 
 
125
  await refresh();
126
  },
127
  [refresh],
@@ -129,9 +149,13 @@ export function useProxies(): ProxiesState {
129
 
130
  const unassignProxy = useCallback(
131
  async (accountId: string) => {
132
- await fetch(`/api/proxies/assign/${encodeURIComponent(accountId)}`, {
133
- method: "DELETE",
134
- });
 
 
 
 
135
  await refresh();
136
  },
137
  [refresh],
@@ -139,11 +163,15 @@ export function useProxies(): ProxiesState {
139
 
140
  const setIntervalMinutes = useCallback(
141
  async (minutes: number) => {
142
- await fetch("/api/proxies/settings", {
143
- method: "PUT",
144
- headers: { "Content-Type": "application/json" },
145
- body: JSON.stringify({ healthCheckIntervalMinutes: minutes }),
146
- });
 
 
 
 
147
  await refresh();
148
  },
149
  [refresh],
 
82
 
83
  const checkProxy = useCallback(
84
  async (id: string) => {
85
+ try {
86
+ await fetch(`/api/proxies/${encodeURIComponent(id)}/check`, {
87
+ method: "POST",
88
+ });
89
+ } catch {
90
+ // network error — refresh will show stale state
91
+ }
92
  await refresh();
93
  },
94
  [refresh],
95
  );
96
 
97
  const checkAll = useCallback(async () => {
98
+ try {
99
+ await fetch("/api/proxies/check-all", { method: "POST" });
100
+ } catch {
101
+ // network error
102
+ }
103
  await refresh();
104
  }, [refresh]);
105
 
106
  const enableProxy = useCallback(
107
  async (id: string) => {
108
+ try {
109
+ await fetch(`/api/proxies/${encodeURIComponent(id)}/enable`, {
110
+ method: "POST",
111
+ });
112
+ } catch {
113
+ // network error
114
+ }
115
  await refresh();
116
  },
117
  [refresh],
 
119
 
120
  const disableProxy = useCallback(
121
  async (id: string) => {
122
+ try {
123
+ await fetch(`/api/proxies/${encodeURIComponent(id)}/disable`, {
124
+ method: "POST",
125
+ });
126
+ } catch {
127
+ // network error
128
+ }
129
  await refresh();
130
  },
131
  [refresh],
 
133
 
134
  const assignProxy = useCallback(
135
  async (accountId: string, proxyId: string) => {
136
+ try {
137
+ await fetch("/api/proxies/assign", {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify({ accountId, proxyId }),
141
+ });
142
+ } catch {
143
+ // network error
144
+ }
145
  await refresh();
146
  },
147
  [refresh],
 
149
 
150
  const unassignProxy = useCallback(
151
  async (accountId: string) => {
152
+ try {
153
+ await fetch(`/api/proxies/assign/${encodeURIComponent(accountId)}`, {
154
+ method: "DELETE",
155
+ });
156
+ } catch {
157
+ // network error
158
+ }
159
  await refresh();
160
  },
161
  [refresh],
 
163
 
164
  const setIntervalMinutes = useCallback(
165
  async (minutes: number) => {
166
+ try {
167
+ await fetch("/api/proxies/settings", {
168
+ method: "PUT",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ healthCheckIntervalMinutes: minutes }),
171
+ });
172
+ } catch {
173
+ // network error
174
+ }
175
  await refresh();
176
  },
177
  [refresh],
src/proxy/proxy-pool.ts CHANGED
@@ -78,11 +78,18 @@ export class ProxyPool {
78
  // ── CRUD ──────────────────────────────────────────────────────────
79
 
80
  add(name: string, url: string): string {
 
 
 
 
 
 
 
81
  const id = randomHex(8);
82
  const entry: ProxyEntry = {
83
  id,
84
  name: name.trim(),
85
- url: url.trim(),
86
  status: "active",
87
  health: null,
88
  addedAt: new Date().toISOString(),
@@ -121,6 +128,11 @@ export class ProxyPool {
121
  return Array.from(this.proxies.values());
122
  }
123
 
 
 
 
 
 
124
  getById(id: string): ProxyEntry | undefined {
125
  return this.proxies.get(id);
126
  }
@@ -285,17 +297,17 @@ export class ProxyPool {
285
  }
286
 
287
  async healthCheckAll(): Promise<void> {
288
- const ids = Array.from(this.proxies.keys());
289
- if (ids.length === 0) return;
 
 
290
 
291
- console.log(`[ProxyPool] Health checking ${ids.length} proxies...`);
292
- await Promise.allSettled(ids.map((id) => this.healthCheck(id)));
293
 
294
- const active = Array.from(this.proxies.values()).filter(
295
- (p) => p.status === "active",
296
- ).length;
297
  console.log(
298
- `[ProxyPool] Health check complete: ${active}/${ids.length} active`,
299
  );
300
  }
301
 
@@ -438,6 +450,16 @@ export class ProxyPool {
438
 
439
  // ── Helpers ─────────────────────────────────────────────────────────
440
 
 
 
 
 
 
 
 
 
 
 
441
  function randomHex(bytes: number): string {
442
  const arr = new Uint8Array(bytes);
443
  crypto.getRandomValues(arr);
 
78
  // ── CRUD ──────────────────────────────────────────────────────────
79
 
80
  add(name: string, url: string): string {
81
+ const trimmedUrl = url.trim();
82
+ // Reject duplicate URLs
83
+ for (const existing of this.proxies.values()) {
84
+ if (existing.url === trimmedUrl) {
85
+ return existing.id;
86
+ }
87
+ }
88
  const id = randomHex(8);
89
  const entry: ProxyEntry = {
90
  id,
91
  name: name.trim(),
92
+ url: trimmedUrl,
93
  status: "active",
94
  health: null,
95
  addedAt: new Date().toISOString(),
 
128
  return Array.from(this.proxies.values());
129
  }
130
 
131
+ /** Returns all proxies with credentials masked in URLs. */
132
+ getAllMasked(): ProxyEntry[] {
133
+ return this.getAll().map((p) => ({ ...p, url: maskProxyUrl(p.url) }));
134
+ }
135
+
136
  getById(id: string): ProxyEntry | undefined {
137
  return this.proxies.get(id);
138
  }
 
297
  }
298
 
299
  async healthCheckAll(): Promise<void> {
300
+ const targets = Array.from(this.proxies.values()).filter(
301
+ (p) => p.status !== "disabled",
302
+ );
303
+ if (targets.length === 0) return;
304
 
305
+ console.log(`[ProxyPool] Health checking ${targets.length} proxies...`);
306
+ await Promise.allSettled(targets.map((p) => this.healthCheck(p.id)));
307
 
308
+ const active = targets.filter((p) => p.status === "active").length;
 
 
309
  console.log(
310
+ `[ProxyPool] Health check complete: ${active}/${targets.length} active`,
311
  );
312
  }
313
 
 
450
 
451
  // ── Helpers ─────────────────────────────────────────────────────────
452
 
453
+ function maskProxyUrl(url: string): string {
454
+ try {
455
+ const u = new URL(url);
456
+ if (u.password) u.password = "***";
457
+ return u.toString();
458
+ } catch {
459
+ return url;
460
+ }
461
+ }
462
+
463
  function randomHex(bytes: number): string {
464
  const arr = new Uint8Array(bytes);
465
  crypto.getRandomValues(arr);
src/routes/proxies.ts CHANGED
@@ -20,10 +20,10 @@ import type { ProxyPool } from "../proxy/proxy-pool.js";
20
  export function createProxyRoutes(proxyPool: ProxyPool): Hono {
21
  const app = new Hono();
22
 
23
- // List all proxies + assignments
24
  app.get("/api/proxies", (c) => {
25
  return c.json({
26
- proxies: proxyPool.getAll(),
27
  assignments: proxyPool.getAllAssignments(),
28
  healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
29
  });
@@ -39,9 +39,14 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
39
  return c.json({ error: "url is required" });
40
  }
41
 
42
- // Basic URL validation
43
  try {
44
- new URL(url);
 
 
 
 
 
45
  } catch {
46
  c.status(400);
47
  return c.json({ error: "Invalid proxy URL format" });
@@ -114,12 +119,12 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
114
  return c.json({ success: true, proxy: proxyPool.getById(id) });
115
  });
116
 
117
- // Health check all — must be before /:id routes
118
  app.post("/api/proxies/check-all", async (c) => {
119
  await proxyPool.healthCheckAll();
120
  return c.json({
121
  success: true,
122
- proxies: proxyPool.getAll(),
123
  });
124
  });
125
 
 
20
  export function createProxyRoutes(proxyPool: ProxyPool): Hono {
21
  const app = new Hono();
22
 
23
+ // List all proxies + assignments (credentials masked)
24
  app.get("/api/proxies", (c) => {
25
  return c.json({
26
+ proxies: proxyPool.getAllMasked(),
27
  assignments: proxyPool.getAllAssignments(),
28
  healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
29
  });
 
39
  return c.json({ error: "url is required" });
40
  }
41
 
42
+ // URL validation + scheme check
43
  try {
44
+ const parsed = new URL(url);
45
+ const allowed = ["http:", "https:", "socks5:", "socks5h:"];
46
+ if (!allowed.includes(parsed.protocol)) {
47
+ c.status(400);
48
+ return c.json({ error: `Unsupported protocol "${parsed.protocol}". Use http, https, socks5, or socks5h.` });
49
+ }
50
  } catch {
51
  c.status(400);
52
  return c.json({ error: "Invalid proxy URL format" });
 
119
  return c.json({ success: true, proxy: proxyPool.getById(id) });
120
  });
121
 
122
+ // Health check all (no route conflict different path structure from /:id/*)
123
  app.post("/api/proxies/check-all", async (c) => {
124
  await proxyPool.healthCheckAll();
125
  return c.json({
126
  success: true,
127
+ proxies: proxyPool.getAllMasked(),
128
  });
129
  });
130
 
web/src/components/ProxyPool.tsx CHANGED
@@ -18,18 +18,6 @@ const statusStyles: Record<string, [string, TranslationKey]> = {
18
  ],
19
  };
20
 
21
- function maskUrl(url: string): string {
22
- try {
23
- const u = new URL(url);
24
- if (u.password) {
25
- u.password = "***";
26
- }
27
- return u.toString();
28
- } catch {
29
- return url;
30
- }
31
- }
32
-
33
  interface ProxyPoolProps {
34
  proxies: ProxiesState;
35
  }
@@ -177,7 +165,7 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
177
  </span>
178
  </div>
179
  <p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
180
- {maskUrl(proxy.url)}
181
  </p>
182
  </div>
183
  </div>
 
18
  ],
19
  };
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  interface ProxyPoolProps {
22
  proxies: ProxiesState;
23
  }
 
165
  </span>
166
  </div>
167
  <p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
168
+ {proxy.url}
169
  </p>
170
  </div>
171
  </div>