@llmindset/hf-mcp 0.3.1 → 0.3.3

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.
Files changed (161) hide show
  1. package/dist/docs-search/doc-fetch.d.ts +1 -0
  2. package/dist/docs-search/doc-fetch.d.ts.map +1 -1
  3. package/dist/docs-search/doc-fetch.js +9 -12
  4. package/dist/docs-search/doc-fetch.js.map +1 -1
  5. package/dist/docs-search/doc-fetch.test.js +56 -11
  6. package/dist/docs-search/doc-fetch.test.js.map +1 -1
  7. package/dist/docs-search/docs-semantic-search.d.ts.map +1 -1
  8. package/dist/docs-search/docs-semantic-search.js +7 -1
  9. package/dist/docs-search/docs-semantic-search.js.map +1 -1
  10. package/dist/file-icons.d.ts +3 -0
  11. package/dist/file-icons.d.ts.map +1 -0
  12. package/dist/file-icons.js +38 -0
  13. package/dist/file-icons.js.map +1 -0
  14. package/dist/gradio-files.d.ts +0 -1
  15. package/dist/gradio-files.d.ts.map +1 -1
  16. package/dist/gradio-files.js +2 -35
  17. package/dist/gradio-files.js.map +1 -1
  18. package/dist/hf-api-call.d.ts.map +1 -1
  19. package/dist/hf-api-call.js +7 -7
  20. package/dist/hf-api-call.js.map +1 -1
  21. package/dist/hub-inspect.d.ts +2 -2
  22. package/dist/hub-inspect.d.ts.map +1 -1
  23. package/dist/hub-inspect.js +1 -1
  24. package/dist/hub-inspect.js.map +1 -1
  25. package/dist/index.browser.d.ts +48 -0
  26. package/dist/index.browser.d.ts.map +1 -0
  27. package/dist/index.browser.js +153 -0
  28. package/dist/index.browser.js.map +1 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/jobs/commands/uv-utils.d.ts +0 -3
  34. package/dist/jobs/commands/uv-utils.d.ts.map +1 -1
  35. package/dist/jobs/commands/uv-utils.js +2 -2
  36. package/dist/jobs/commands/uv-utils.js.map +1 -1
  37. package/dist/jobs/jobs-tool.d.ts.map +1 -1
  38. package/dist/jobs/jobs-tool.js +11 -12
  39. package/dist/jobs/jobs-tool.js.map +1 -1
  40. package/dist/jobs/schema-help.d.ts +2 -9
  41. package/dist/jobs/schema-help.d.ts.map +1 -1
  42. package/dist/jobs/schema-help.js +3 -3
  43. package/dist/jobs/schema-help.js.map +1 -1
  44. package/dist/jobs/sse-handler.d.ts +3 -2
  45. package/dist/jobs/sse-handler.d.ts.map +1 -1
  46. package/dist/jobs/sse-handler.js +8 -4
  47. package/dist/jobs/sse-handler.js.map +1 -1
  48. package/dist/jobs/types.d.ts +1 -1
  49. package/dist/logger.d.ts +2 -2
  50. package/dist/logger.d.ts.map +1 -1
  51. package/dist/network/fetch-profile.d.ts +24 -0
  52. package/dist/network/fetch-profile.d.ts.map +1 -0
  53. package/dist/network/fetch-profile.js +80 -0
  54. package/dist/network/fetch-profile.js.map +1 -0
  55. package/dist/network/index.d.ts +5 -0
  56. package/dist/network/index.d.ts.map +1 -0
  57. package/dist/network/index.js +5 -0
  58. package/dist/network/index.js.map +1 -0
  59. package/dist/network/ip-policy.d.ts +6 -0
  60. package/dist/network/ip-policy.d.ts.map +1 -0
  61. package/dist/network/ip-policy.js +166 -0
  62. package/dist/network/ip-policy.js.map +1 -0
  63. package/dist/network/ip-policy.test.d.ts +2 -0
  64. package/dist/network/ip-policy.test.d.ts.map +1 -0
  65. package/dist/network/ip-policy.test.js +26 -0
  66. package/dist/network/ip-policy.test.js.map +1 -0
  67. package/dist/network/safe-fetch.d.ts +16 -0
  68. package/dist/network/safe-fetch.d.ts.map +1 -0
  69. package/dist/network/safe-fetch.js +124 -0
  70. package/dist/network/safe-fetch.js.map +1 -0
  71. package/dist/network/safe-fetch.test.d.ts +2 -0
  72. package/dist/network/safe-fetch.test.d.ts.map +1 -0
  73. package/dist/network/safe-fetch.test.js +136 -0
  74. package/dist/network/safe-fetch.test.js.map +1 -0
  75. package/dist/network/url-policy.d.ts +32 -0
  76. package/dist/network/url-policy.d.ts.map +1 -0
  77. package/dist/network/url-policy.js +230 -0
  78. package/dist/network/url-policy.js.map +1 -0
  79. package/dist/network/url-policy.test.d.ts +2 -0
  80. package/dist/network/url-policy.test.d.ts.map +1 -0
  81. package/dist/network/url-policy.test.js +57 -0
  82. package/dist/network/url-policy.test.js.map +1 -0
  83. package/dist/readme-utils.d.ts.map +1 -1
  84. package/dist/readme-utils.js +3 -4
  85. package/dist/readme-utils.js.map +1 -1
  86. package/dist/repo-search.d.ts +46 -0
  87. package/dist/repo-search.d.ts.map +1 -0
  88. package/dist/repo-search.js +310 -0
  89. package/dist/repo-search.js.map +1 -0
  90. package/dist/repo-search.test.d.ts +2 -0
  91. package/dist/repo-search.test.d.ts.map +1 -0
  92. package/dist/repo-search.test.js +130 -0
  93. package/dist/repo-search.test.js.map +1 -0
  94. package/dist/space/commands/discover.d.ts +0 -5
  95. package/dist/space/commands/discover.d.ts.map +1 -1
  96. package/dist/space/commands/discover.js +9 -2
  97. package/dist/space/commands/discover.js.map +1 -1
  98. package/dist/space/commands/invoke.js +1 -59
  99. package/dist/space/commands/invoke.js.map +1 -1
  100. package/dist/space/commands/view-parameters.d.ts.map +1 -1
  101. package/dist/space/commands/view-parameters.js +3 -98
  102. package/dist/space/commands/view-parameters.js.map +1 -1
  103. package/dist/space/dynamic-space-tool.d.ts.map +1 -1
  104. package/dist/space/dynamic-space-tool.js +5 -2
  105. package/dist/space/dynamic-space-tool.js.map +1 -1
  106. package/dist/space/utils/gradio-caller.d.ts.map +1 -1
  107. package/dist/space/utils/gradio-caller.js +13 -6
  108. package/dist/space/utils/gradio-caller.js.map +1 -1
  109. package/dist/space/utils/space-http.d.ts +8 -0
  110. package/dist/space/utils/space-http.d.ts.map +1 -0
  111. package/dist/space/utils/space-http.js +49 -0
  112. package/dist/space/utils/space-http.js.map +1 -0
  113. package/dist/space-files.d.ts +0 -1
  114. package/dist/space-files.d.ts.map +1 -1
  115. package/dist/space-files.js +3 -36
  116. package/dist/space-files.js.map +1 -1
  117. package/dist/tool-ids.d.ts +6 -5
  118. package/dist/tool-ids.d.ts.map +1 -1
  119. package/dist/tool-ids.js +9 -14
  120. package/dist/tool-ids.js.map +1 -1
  121. package/package.json +7 -3
  122. package/src/docs-search/doc-fetch.test.ts +98 -28
  123. package/src/docs-search/doc-fetch.ts +9 -16
  124. package/src/docs-search/docs-semantic-search.ts +8 -1
  125. package/src/file-icons.ts +39 -0
  126. package/src/gradio-files.ts +2 -40
  127. package/src/hf-api-call.ts +8 -10
  128. package/src/hub-inspect.ts +2 -2
  129. package/src/index.browser.ts +183 -0
  130. package/src/index.ts +2 -0
  131. package/src/jobs/commands/uv-utils.ts +2 -2
  132. package/src/jobs/jobs-tool.ts +13 -12
  133. package/src/jobs/schema-help.ts +4 -4
  134. package/src/jobs/sse-handler.ts +12 -7
  135. package/src/logger.ts +2 -2
  136. package/src/network/fetch-profile.ts +112 -0
  137. package/src/network/index.ts +4 -0
  138. package/src/network/ip-policy.test.ts +29 -0
  139. package/src/network/ip-policy.ts +206 -0
  140. package/src/network/safe-fetch.test.ts +181 -0
  141. package/src/network/safe-fetch.ts +174 -0
  142. package/src/network/url-policy.test.ts +100 -0
  143. package/src/network/url-policy.ts +304 -0
  144. package/src/readme-utils.ts +11 -10
  145. package/src/repo-search.test.ts +155 -0
  146. package/src/repo-search.ts +414 -0
  147. package/src/space/commands/discover.ts +10 -2
  148. package/src/space/commands/invoke.ts +1 -88
  149. package/src/space/commands/view-parameters.ts +3 -136
  150. package/src/space/dynamic-space-tool.ts +6 -2
  151. package/src/space/utils/gradio-caller.ts +25 -12
  152. package/src/space/utils/space-http.ts +75 -0
  153. package/src/space-files.ts +3 -41
  154. package/src/tool-ids.ts +10 -14
  155. package/test/fetch-guard.spec.ts +70 -0
  156. package/test/jobs/sse-handler.spec.ts +60 -0
  157. package/dist/space/utils/result-formatter.d.ts +0 -4
  158. package/dist/space/utils/result-formatter.d.ts.map +0 -1
  159. package/dist/space/utils/result-formatter.js +0 -146
  160. package/dist/space/utils/result-formatter.js.map +0 -1
  161. package/src/space/utils/result-formatter.ts +0 -226
@@ -6,7 +6,7 @@ export const UV_DEFAULT_IMAGE = 'ghcr.io/astral-sh/uv:python3.12-bookworm';
6
6
  type UvCommandOptions = Pick<UvArgs, 'with_deps' | 'python' | 'script_args'>;
7
7
  type UvCommandLikeArgs = Pick<UvArgs, 'script' | 'with_deps' | 'python' | 'script_args'>;
8
8
 
9
- export function buildUvCommand(script: string, args: UvCommandOptions): string[] {
9
+ function buildUvCommand(script: string, args: UvCommandOptions): string[] {
10
10
  const parts: string[] = ['uv', 'run'];
11
11
 
12
12
  if (args.with_deps && args.with_deps.length > 0) {
@@ -28,7 +28,7 @@ export function buildUvCommand(script: string, args: UvCommandOptions): string[]
28
28
  return parts;
29
29
  }
30
30
 
31
- export function wrapInlineScript(script: string, args: UvCommandOptions): string {
31
+ function wrapInlineScript(script: string, args: UvCommandOptions): string {
32
32
  const encoded = Buffer.from(script, 'utf-8').toString('base64');
33
33
  const baseCommand = shellQuote(buildUvCommand('-', args));
34
34
  return `echo "${encoded}" | base64 -d | ${baseCommand}`;
@@ -162,6 +162,17 @@ const HARDWARE_FLAVORS_SECTION = [
162
162
  .filter((line): line is string => Boolean(line))
163
163
  .join('\n');
164
164
 
165
+ const UNKNOWN_OPERATION_INSTRUCTIONS = `Available operations:
166
+ - run, uv, ps, logs, inspect, cancel
167
+ - scheduled run, scheduled uv, scheduled ps, scheduled inspect, scheduled delete, scheduled suspend, scheduled resume
168
+
169
+ Call this tool with no operation for full usage instructions.`;
170
+
171
+ function formatUnknownOperationMessage(requestedOperation?: string): string {
172
+ return `Unknown operation: "${requestedOperation ?? 'unknown'}"
173
+ ${UNKNOWN_OPERATION_INSTRUCTIONS}`;
174
+ }
175
+
165
176
  function isHelpRequested(args: Record<string, unknown> | undefined): boolean {
166
177
  if (!args) {
167
178
  return false;
@@ -426,12 +437,7 @@ export class HfJobsTool {
426
437
  const normalizedOperation = requestedOperation.toLowerCase();
427
438
  if (!isOperationName(normalizedOperation)) {
428
439
  return {
429
- formatted: `Unknown operation: "${requestedOperation}"
430
- Available operations:
431
- - run, uv, ps, logs, inspect, cancel
432
- - scheduled run, scheduled uv, scheduled ps, scheduled inspect, scheduled delete, scheduled suspend, scheduled resume
433
-
434
- Call this tool with no operation for full usage instructions.`,
440
+ formatted: formatUnknownOperationMessage(requestedOperation),
435
441
  totalResults: 0,
436
442
  resultsShared: 0,
437
443
  };
@@ -540,12 +546,7 @@ Call this tool with no operation for full usage instructions.`,
540
546
 
541
547
  default:
542
548
  return {
543
- formatted: `Unknown operation: "${requestedOperation ?? 'unknown'}"
544
- Available operations:
545
- - run, uv, ps, logs, inspect, cancel
546
- - scheduled run, scheduled uv, scheduled ps, scheduled inspect, scheduled delete, scheduled suspend, scheduled resume
547
-
548
- Call this tool with no operation for full usage instructions.`,
549
+ formatted: formatUnknownOperationMessage(requestedOperation),
549
550
  totalResults: 0,
550
551
  resultsShared: 0,
551
552
  };
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
 
8
8
  export type AnyZodType = z.ZodType<unknown, z.ZodTypeDef, unknown>;
9
9
 
10
- export interface FieldDetails {
10
+ interface FieldDetails {
11
11
  key: string;
12
12
  description?: string;
13
13
  typeLabel: string;
@@ -19,7 +19,7 @@ export interface FieldDetails {
19
19
  /**
20
20
  * Unwrap optional/default/nullable wrappers to find the core type definition.
21
21
  */
22
- export function unwrapType(zodType: AnyZodType): {
22
+ function unwrapType(zodType: AnyZodType): {
23
23
  baseType: AnyZodType;
24
24
  isOptional: boolean;
25
25
  isNullable: boolean;
@@ -72,7 +72,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
72
72
  /**
73
73
  * Generate a human-readable type label from a Zod type definition
74
74
  */
75
- export function labelForType(zodType: AnyZodType): string {
75
+ function labelForType(zodType: AnyZodType): string {
76
76
  if (zodType instanceof z.ZodString) {
77
77
  return 'string';
78
78
  }
@@ -151,7 +151,7 @@ export function extractFieldDetails(schema: AnyZodType): FieldDetails[] {
151
151
  /**
152
152
  * Format a default value for display in help text
153
153
  */
154
- export function formatDefaultValue(value: unknown): string | undefined {
154
+ function formatDefaultValue(value: unknown): string | undefined {
155
155
  if (value === undefined) {
156
156
  return undefined;
157
157
  }
@@ -1,4 +1,5 @@
1
1
  import type { LogEvent } from './types.js';
2
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from '../network/fetch-profile.js';
2
3
 
3
4
  /**
4
5
  * Default duration to wait for logs when not detached (in milliseconds)
@@ -18,7 +19,7 @@ export const DEFAULT_MAX_LOG_LINES = 20;
18
19
  /**
19
20
  * Options for fetching logs via SSE
20
21
  */
21
- export interface SseLogOptions {
22
+ interface SseLogOptions {
22
23
  /** Maximum time to collect logs in milliseconds (default: 10000 = 10s) */
23
24
  maxDuration?: number;
24
25
  /** Maximum number of lines to return (default: 20) */
@@ -30,7 +31,7 @@ export interface SseLogOptions {
30
31
  /**
31
32
  * Result from fetching logs
32
33
  */
33
- export interface SseLogResult {
34
+ interface SseLogResult {
34
35
  /** Log lines collected */
35
36
  logs: string[];
36
37
  /** Whether the job finished during collection */
@@ -69,9 +70,12 @@ export async function fetchJobLogs(url: string, options: SseLogOptions = {}): Pr
69
70
  headers['Authorization'] = `Bearer ${token}`;
70
71
  }
71
72
 
72
- const response = await fetch(url, {
73
- headers,
74
- signal: controller.signal,
73
+ const { response } = await fetchWithProfile(url, NETWORK_FETCH_PROFILES.externalHttps(), {
74
+ timeoutMs: maxDuration,
75
+ requestInit: {
76
+ headers,
77
+ signal: controller.signal,
78
+ },
75
79
  });
76
80
 
77
81
  if (!response.ok) {
@@ -140,8 +144,9 @@ export async function fetchJobLogs(url: string, options: SseLogOptions = {}): Pr
140
144
  // Close the reader
141
145
  await reader.cancel();
142
146
  } catch (error) {
143
- // If aborted due to timeout, that's expected
144
- if ((error as Error).name !== 'AbortError') {
147
+ // Timeouts abort the stream while waiting for more SSE chunks.
148
+ // Treat any timeout-triggered abort as expected truncation.
149
+ if (!truncated && !controller.signal.aborted) {
145
150
  throw error;
146
151
  }
147
152
  } finally {
package/src/logger.ts CHANGED
@@ -1,6 +1,6 @@
1
- export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
1
+ type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
2
2
 
3
- export interface Logger {
3
+ interface Logger {
4
4
  trace: (...args: unknown[]) => void;
5
5
  debug: (...args: unknown[]) => void;
6
6
  info: (...args: unknown[]) => void;
@@ -0,0 +1,112 @@
1
+ import { safeFetch, type SafeFetchResult } from './safe-fetch.js';
2
+ import {
3
+ createExternalHttpsPolicy,
4
+ createGradioMcpHostPolicy,
5
+ createGradioSchemaHostPolicy,
6
+ createHfDocsPolicy,
7
+ createHttpOrHttpsPolicy,
8
+ createHuggingFaceHubPolicy,
9
+ createLocalhostHttpPolicy,
10
+ isLocalhostHostname,
11
+ type UrlProtocol,
12
+ type UrlPolicy,
13
+ } from './url-policy.js';
14
+
15
+ const DEFAULT_TIMEOUT_MS = 12_500;
16
+ const DEFAULT_MAX_REDIRECTS = 3;
17
+
18
+ export interface SafeFetchProfile {
19
+ urlPolicy: UrlPolicy;
20
+ timeoutMs: number;
21
+ maxRedirects: number;
22
+ externalOnly: boolean;
23
+ }
24
+
25
+ export interface FetchWithProfileOptions {
26
+ requestInit?: RequestInit;
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ export async function fetchWithProfile(
31
+ url: string | URL,
32
+ profile: SafeFetchProfile,
33
+ options: FetchWithProfileOptions = {}
34
+ ): Promise<SafeFetchResult> {
35
+ return safeFetch(url, {
36
+ urlPolicy: profile.urlPolicy,
37
+ timeoutMs: options.timeoutMs ?? profile.timeoutMs,
38
+ maxRedirects: profile.maxRedirects,
39
+ externalOnly: profile.externalOnly,
40
+ requestInit: options.requestInit,
41
+ });
42
+ }
43
+
44
+ export const NETWORK_FETCH_PROFILES = {
45
+ externalHttps(): SafeFetchProfile {
46
+ return {
47
+ urlPolicy: createExternalHttpsPolicy(),
48
+ timeoutMs: DEFAULT_TIMEOUT_MS,
49
+ maxRedirects: DEFAULT_MAX_REDIRECTS,
50
+ externalOnly: true,
51
+ };
52
+ },
53
+ httpOrHttpsPermissive(): SafeFetchProfile {
54
+ return {
55
+ urlPolicy: createHttpOrHttpsPolicy(),
56
+ timeoutMs: DEFAULT_TIMEOUT_MS,
57
+ maxRedirects: DEFAULT_MAX_REDIRECTS,
58
+ externalOnly: false,
59
+ };
60
+ },
61
+ streamableProxy(): SafeFetchProfile {
62
+ return {
63
+ urlPolicy: createHttpOrHttpsPolicy(),
64
+ timeoutMs: 0,
65
+ maxRedirects: DEFAULT_MAX_REDIRECTS,
66
+ externalOnly: false,
67
+ };
68
+ },
69
+ hfHub(): SafeFetchProfile {
70
+ return {
71
+ urlPolicy: createHuggingFaceHubPolicy(),
72
+ timeoutMs: DEFAULT_TIMEOUT_MS,
73
+ maxRedirects: DEFAULT_MAX_REDIRECTS,
74
+ externalOnly: true,
75
+ };
76
+ },
77
+ hfDocs(): SafeFetchProfile {
78
+ return {
79
+ urlPolicy: createHfDocsPolicy(),
80
+ timeoutMs: DEFAULT_TIMEOUT_MS,
81
+ maxRedirects: 5,
82
+ externalOnly: true,
83
+ };
84
+ },
85
+ localhostHttp(): SafeFetchProfile {
86
+ return {
87
+ urlPolicy: createLocalhostHttpPolicy(),
88
+ timeoutMs: DEFAULT_TIMEOUT_MS,
89
+ maxRedirects: 2,
90
+ externalOnly: false,
91
+ };
92
+ },
93
+ gradioSchemaHost(hostname: string): SafeFetchProfile {
94
+ return {
95
+ urlPolicy: createGradioSchemaHostPolicy(hostname),
96
+ timeoutMs: 10_000,
97
+ maxRedirects: 2,
98
+ externalOnly: !isLocalhostHostname(hostname),
99
+ };
100
+ },
101
+ gradioMcpHost(
102
+ hostname: string,
103
+ allowedProtocol: UrlProtocol
104
+ ): SafeFetchProfile {
105
+ return {
106
+ urlPolicy: createGradioMcpHostPolicy(hostname, allowedProtocol),
107
+ timeoutMs: 0,
108
+ maxRedirects: 0,
109
+ externalOnly: !isLocalhostHostname(hostname) && process.env.NODE_ENV !== 'test',
110
+ };
111
+ },
112
+ };
@@ -0,0 +1,4 @@
1
+ export * from './url-policy.js';
2
+ export * from './ip-policy.js';
3
+ export * from './safe-fetch.js';
4
+ export * from './fetch-profile.js';
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertExternalAddress, isIpInternalOrReserved } from './ip-policy.js';
3
+
4
+ describe('ip-policy', () => {
5
+ it('classifies internal/reserved IPv4 ranges', () => {
6
+ expect(isIpInternalOrReserved('127.0.0.1')).toBe(true);
7
+ expect(isIpInternalOrReserved('10.1.2.3')).toBe(true);
8
+ expect(isIpInternalOrReserved('172.20.10.2')).toBe(true);
9
+ expect(isIpInternalOrReserved('192.168.1.1')).toBe(true);
10
+ expect(isIpInternalOrReserved('8.8.8.8')).toBe(false);
11
+ });
12
+
13
+ it('classifies internal/reserved IPv6 ranges', () => {
14
+ expect(isIpInternalOrReserved('::1')).toBe(true);
15
+ expect(isIpInternalOrReserved('fc00::1')).toBe(true);
16
+ expect(isIpInternalOrReserved('fe80::1')).toBe(true);
17
+ expect(isIpInternalOrReserved('2001:db8::1')).toBe(true);
18
+ expect(isIpInternalOrReserved('2607:f8b0:4005:80a::200e')).toBe(false);
19
+ });
20
+
21
+ it('blocks internal literal addresses in assertExternalAddress', async () => {
22
+ await expect(assertExternalAddress('127.0.0.1')).rejects.toThrow('Blocked internal or reserved address');
23
+ await expect(assertExternalAddress('::1')).rejects.toThrow('Blocked internal or reserved address');
24
+ });
25
+
26
+ it('allows external literal addresses in assertExternalAddress', async () => {
27
+ await expect(assertExternalAddress('8.8.8.8')).resolves.toBeUndefined();
28
+ });
29
+ });
@@ -0,0 +1,206 @@
1
+ export interface ExternalAddressOptions {
2
+ allowDnsRebindMitigation?: boolean;
3
+ }
4
+
5
+ function normalizeIpLiteral(host: string): string {
6
+ if (host.startsWith('[') && host.endsWith(']')) {
7
+ return host.slice(1, -1);
8
+ }
9
+ return host;
10
+ }
11
+
12
+ function parseIpv4ToInt(ip: string): number {
13
+ const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
14
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
15
+ throw new Error(`Invalid IPv4 address: ${ip}`);
16
+ }
17
+
18
+ return parts.reduce((acc, part) => acc * 256 + part, 0);
19
+ }
20
+
21
+ function ipv4InRange(ipValue: number, start: string, end: string): boolean {
22
+ const startValue = parseIpv4ToInt(start);
23
+ const endValue = parseIpv4ToInt(end);
24
+ return ipValue >= startValue && ipValue <= endValue;
25
+ }
26
+
27
+ function isIpv4InternalOrReserved(ip: string): boolean {
28
+ const value = parseIpv4ToInt(ip);
29
+
30
+ return (
31
+ ipv4InRange(value, '0.0.0.0', '0.255.255.255') ||
32
+ ipv4InRange(value, '10.0.0.0', '10.255.255.255') ||
33
+ ipv4InRange(value, '100.64.0.0', '100.127.255.255') ||
34
+ ipv4InRange(value, '127.0.0.0', '127.255.255.255') ||
35
+ ipv4InRange(value, '169.254.0.0', '169.254.255.255') ||
36
+ ipv4InRange(value, '172.16.0.0', '172.31.255.255') ||
37
+ ipv4InRange(value, '192.0.0.0', '192.0.0.255') ||
38
+ ipv4InRange(value, '192.0.2.0', '192.0.2.255') ||
39
+ ipv4InRange(value, '192.88.99.0', '192.88.99.255') ||
40
+ ipv4InRange(value, '192.168.0.0', '192.168.255.255') ||
41
+ ipv4InRange(value, '198.18.0.0', '198.19.255.255') ||
42
+ ipv4InRange(value, '198.51.100.0', '198.51.100.255') ||
43
+ ipv4InRange(value, '203.0.113.0', '203.0.113.255') ||
44
+ ipv4InRange(value, '224.0.0.0', '239.255.255.255') ||
45
+ ipv4InRange(value, '240.0.0.0', '255.255.255.255')
46
+ );
47
+ }
48
+
49
+ function parseIpv6ToBigInt(ip: string): bigint {
50
+ const zoneIndex = ip.indexOf('%');
51
+ const zoneStripped = zoneIndex >= 0 ? ip.slice(0, zoneIndex) : ip;
52
+
53
+ let working = zoneStripped;
54
+ if (working.includes('.')) {
55
+ const lastColon = working.lastIndexOf(':');
56
+ if (lastColon < 0) {
57
+ throw new Error(`Invalid IPv6 address: ${ip}`);
58
+ }
59
+ const ipv4Part = working.slice(lastColon + 1);
60
+ const ipv4Value = parseIpv4ToInt(ipv4Part);
61
+ const high = ((ipv4Value >>> 16) & 0xffff).toString(16);
62
+ const low = (ipv4Value & 0xffff).toString(16);
63
+ working = `${working.slice(0, lastColon)}:${high}:${low}`;
64
+ }
65
+
66
+ const split = working.split('::');
67
+ if (split.length > 2) {
68
+ throw new Error(`Invalid IPv6 address: ${ip}`);
69
+ }
70
+
71
+ const left = split[0] ? split[0].split(':').filter(Boolean) : [];
72
+ const right = split[1] ? split[1].split(':').filter(Boolean) : [];
73
+ const missingCount = 8 - (left.length + right.length);
74
+
75
+ if (split.length === 1 && missingCount !== 0) {
76
+ throw new Error(`Invalid IPv6 address: ${ip}`);
77
+ }
78
+
79
+ if (missingCount < 0) {
80
+ throw new Error(`Invalid IPv6 address: ${ip}`);
81
+ }
82
+
83
+ const full = [...left, ...Array.from({ length: missingCount }, () => '0'), ...right];
84
+ if (full.length !== 8) {
85
+ throw new Error(`Invalid IPv6 address: ${ip}`);
86
+ }
87
+
88
+ let value = 0n;
89
+ for (const part of full) {
90
+ const segment = Number.parseInt(part, 16);
91
+ if (Number.isNaN(segment) || segment < 0 || segment > 0xffff) {
92
+ throw new Error(`Invalid IPv6 address: ${ip}`);
93
+ }
94
+ value = (value << 16n) + BigInt(segment);
95
+ }
96
+
97
+ return value;
98
+ }
99
+
100
+ function isIpv6InCidr(ipValue: bigint, prefixValue: bigint, prefixLength: number): boolean {
101
+ const hostBits = 128n - BigInt(prefixLength);
102
+ const mask = ((1n << BigInt(prefixLength)) - 1n) << hostBits;
103
+ return (ipValue & mask) === (prefixValue & mask);
104
+ }
105
+
106
+ function isIpv6InternalOrReserved(ip: string): boolean {
107
+ const value = parseIpv6ToBigInt(ip);
108
+
109
+ if (value === 0n || value === 1n) {
110
+ return true;
111
+ }
112
+
113
+ // IPv4-mapped IPv6 ::ffff:a.b.c.d
114
+ if (value >> 32n === 0xffffn) {
115
+ const ipv4Value = Number(value & 0xffffffffn);
116
+ const octet1 = (ipv4Value >>> 24) & 0xff;
117
+ const octet2 = (ipv4Value >>> 16) & 0xff;
118
+ const octet3 = (ipv4Value >>> 8) & 0xff;
119
+ const octet4 = ipv4Value & 0xff;
120
+ return isIpv4InternalOrReserved(
121
+ `${octet1.toString()}.${octet2.toString()}.${octet3.toString()}.${octet4.toString()}`
122
+ );
123
+ }
124
+
125
+ return (
126
+ isIpv6InCidr(value, 0xfc00n << 112n, 7) || // Unique local
127
+ isIpv6InCidr(value, 0xfe80n << 112n, 10) || // Link-local
128
+ isIpv6InCidr(value, 0xff00n << 112n, 8) || // Multicast
129
+ isIpv6InCidr(value, 0x20010db8n << 96n, 32) || // Documentation
130
+ isIpv6InCidr(value, 0x20010010n << 96n, 28) // ORCHID
131
+ );
132
+ }
133
+
134
+ export function isIpInternalOrReserved(ip: string): boolean {
135
+ const normalizedIp = normalizeIpLiteral(ip);
136
+ const ipVersion = detectIpVersion(normalizedIp);
137
+ if (ipVersion === 0) {
138
+ throw new Error(`Invalid IP address: ${ip}`);
139
+ }
140
+
141
+ if (ipVersion === 4) {
142
+ return isIpv4InternalOrReserved(normalizedIp);
143
+ }
144
+
145
+ return isIpv6InternalOrReserved(normalizedIp);
146
+ }
147
+
148
+ async function lookupAll(hostname: string): Promise<string[]> {
149
+ const { lookup } = await import('node:dns/promises');
150
+ const results = await lookup(hostname, { all: true, verbatim: true });
151
+ return results.map((entry) => entry.address);
152
+ }
153
+
154
+ function detectIpVersion(candidate: string): 0 | 4 | 6 {
155
+ try {
156
+ parseIpv4ToInt(candidate);
157
+ return 4;
158
+ } catch {
159
+ // continue to ipv6 parsing
160
+ }
161
+
162
+ try {
163
+ parseIpv6ToBigInt(candidate);
164
+ return 6;
165
+ } catch {
166
+ return 0;
167
+ }
168
+ }
169
+
170
+ export async function assertExternalAddress(hostname: string, options: ExternalAddressOptions = {}): Promise<void> {
171
+ const { allowDnsRebindMitigation = true } = options;
172
+ const normalized = hostname.trim().replace(/\.+$/, '');
173
+
174
+ if (!normalized) {
175
+ throw new Error('Hostname is required for external address check');
176
+ }
177
+
178
+ const ipLiteral = normalizeIpLiteral(normalized);
179
+ const ipVersion = detectIpVersion(ipLiteral);
180
+ if (ipVersion !== 0) {
181
+ if (isIpInternalOrReserved(ipLiteral)) {
182
+ throw new Error(`Blocked internal or reserved address: ${ipLiteral}`);
183
+ }
184
+ return;
185
+ }
186
+
187
+ const firstLookup = await lookupAll(normalized);
188
+ if (firstLookup.length === 0) {
189
+ throw new Error(`No DNS records found for hostname: ${normalized}`);
190
+ }
191
+
192
+ for (const address of firstLookup) {
193
+ if (isIpInternalOrReserved(address)) {
194
+ throw new Error(`Blocked internal or reserved address for hostname ${normalized}: ${address}`);
195
+ }
196
+ }
197
+
198
+ if (allowDnsRebindMitigation) {
199
+ const secondLookup = await lookupAll(normalized);
200
+ for (const address of secondLookup) {
201
+ if (isIpInternalOrReserved(address)) {
202
+ throw new Error(`Blocked internal or reserved address for hostname ${normalized}: ${address}`);
203
+ }
204
+ }
205
+ }
206
+ }
@@ -0,0 +1,181 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createExternalHttpsPolicy, createHfDocsPolicy } from './url-policy.js';
3
+ import { safeFetch } from './safe-fetch.js';
4
+
5
+ describe('safeFetch', () => {
6
+ afterEach(() => {
7
+ vi.clearAllMocks();
8
+ vi.unstubAllGlobals();
9
+ });
10
+
11
+ it('follows redirects manually and validates each hop', async () => {
12
+ const fetchMock = vi
13
+ .fn()
14
+ .mockResolvedValueOnce(new Response('', { status: 302, headers: { location: '/docs/next' } }))
15
+ .mockResolvedValueOnce(new Response('ok', { status: 200 }));
16
+ vi.stubGlobal('fetch', fetchMock);
17
+
18
+ const result = await safeFetch('https://huggingface.co/docs/start', {
19
+ urlPolicy: createHfDocsPolicy(),
20
+ externalOnly: true,
21
+ });
22
+
23
+ expect(result.redirectsFollowed).toBe(1);
24
+ expect(result.finalUrl.toString()).toBe('https://huggingface.co/docs/next');
25
+ expect(fetchMock).toHaveBeenCalledTimes(2);
26
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('https://huggingface.co/docs/start');
27
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('https://huggingface.co/docs/next');
28
+ });
29
+
30
+ it('rejects redirect to disallowed host', async () => {
31
+ const fetchMock = vi
32
+ .fn()
33
+ .mockResolvedValueOnce(new Response('', { status: 302, headers: { location: 'https://example.com/path' } }));
34
+ vi.stubGlobal('fetch', fetchMock);
35
+
36
+ await expect(
37
+ safeFetch('https://huggingface.co/docs/start', {
38
+ urlPolicy: createHfDocsPolicy(),
39
+ externalOnly: true,
40
+ })
41
+ ).rejects.toThrow('URL hostname is not allowed');
42
+ expect(fetchMock).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it('enforces redirect limits', async () => {
46
+ const fetchMock = vi
47
+ .fn()
48
+ .mockResolvedValueOnce(new Response('', { status: 302, headers: { location: '/docs/a' } }))
49
+ .mockResolvedValueOnce(new Response('', { status: 302, headers: { location: '/docs/b' } }));
50
+ vi.stubGlobal('fetch', fetchMock);
51
+
52
+ await expect(
53
+ safeFetch('https://huggingface.co/docs/start', {
54
+ urlPolicy: createHfDocsPolicy(),
55
+ maxRedirects: 1,
56
+ })
57
+ ).rejects.toThrow('Redirect limit exceeded');
58
+ });
59
+
60
+ it('enforces timeout', async () => {
61
+ const fetchMock = vi.fn().mockImplementation(
62
+ (_url: string, init?: RequestInit) =>
63
+ new Promise((_, reject) => {
64
+ const signal = init?.signal;
65
+ signal?.addEventListener(
66
+ 'abort',
67
+ () => {
68
+ reject(new DOMException('aborted', 'AbortError'));
69
+ },
70
+ { once: true }
71
+ );
72
+ })
73
+ );
74
+ vi.stubGlobal('fetch', fetchMock);
75
+
76
+ await expect(
77
+ safeFetch('https://example.com/file.wav', {
78
+ urlPolicy: createExternalHttpsPolicy(),
79
+ timeoutMs: 5,
80
+ })
81
+ ).rejects.toThrow('Request timed out');
82
+ });
83
+
84
+ it('keeps caller abort signal active while streaming response body', async () => {
85
+ const fetchMock = vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
86
+ const stream = new ReadableStream<Uint8Array>({
87
+ start(controller) {
88
+ init?.signal?.addEventListener(
89
+ 'abort',
90
+ () => {
91
+ controller.error(new DOMException('aborted', 'AbortError'));
92
+ },
93
+ { once: true }
94
+ );
95
+ },
96
+ pull() {
97
+ return new Promise<void>(() => {});
98
+ },
99
+ });
100
+
101
+ return Promise.resolve(new Response(stream, { status: 200 }));
102
+ });
103
+ vi.stubGlobal('fetch', fetchMock);
104
+
105
+ const controller = new AbortController();
106
+ const { response } = await safeFetch('https://example.com/file.wav', {
107
+ urlPolicy: createExternalHttpsPolicy(),
108
+ timeoutMs: 500,
109
+ requestInit: { signal: controller.signal },
110
+ });
111
+
112
+ const reader = response.body?.getReader();
113
+ if (!reader) {
114
+ throw new Error('Expected response body to exist');
115
+ }
116
+
117
+ const readPromise = Promise.race([
118
+ reader.read(),
119
+ new Promise<never>((_, reject) => {
120
+ setTimeout(() => {
121
+ reject(new Error('stream read did not abort'));
122
+ }, 100);
123
+ }),
124
+ ]);
125
+ controller.abort();
126
+
127
+ await expect(readPromise).rejects.toMatchObject({ name: 'AbortError' });
128
+ });
129
+
130
+ it('enforces timeout while streaming response body', async () => {
131
+ const fetchMock = vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
132
+ const stream = new ReadableStream<Uint8Array>({
133
+ start(controller) {
134
+ init?.signal?.addEventListener(
135
+ 'abort',
136
+ () => {
137
+ controller.error(new DOMException('aborted', 'AbortError'));
138
+ },
139
+ { once: true }
140
+ );
141
+ },
142
+ pull() {
143
+ return new Promise<void>(() => {});
144
+ },
145
+ });
146
+
147
+ return Promise.resolve(new Response(stream, { status: 200 }));
148
+ });
149
+ vi.stubGlobal('fetch', fetchMock);
150
+
151
+ const { response } = await safeFetch('https://example.com/file.wav', {
152
+ urlPolicy: createExternalHttpsPolicy(),
153
+ timeoutMs: 10,
154
+ });
155
+
156
+ const reader = response.body?.getReader();
157
+ if (!reader) {
158
+ throw new Error('Expected response body to exist');
159
+ }
160
+
161
+ const readPromise = Promise.race([
162
+ reader.read(),
163
+ new Promise<never>((_, reject) => {
164
+ setTimeout(() => {
165
+ reject(new Error('stream read did not timeout'));
166
+ }, 200);
167
+ }),
168
+ ]);
169
+
170
+ await expect(readPromise).rejects.toMatchObject({ name: 'AbortError' });
171
+ });
172
+
173
+ it('blocks internal destinations when externalOnly is enabled', async () => {
174
+ await expect(
175
+ safeFetch('https://127.0.0.1/x', {
176
+ urlPolicy: createExternalHttpsPolicy(),
177
+ externalOnly: true,
178
+ })
179
+ ).rejects.toThrow('Blocked internal or reserved address');
180
+ });
181
+ });