@llmindset/hf-mcp 0.3.2 → 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 (137) 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/file-icons.d.ts +3 -0
  8. package/dist/file-icons.d.ts.map +1 -0
  9. package/dist/file-icons.js +38 -0
  10. package/dist/file-icons.js.map +1 -0
  11. package/dist/gradio-files.d.ts +0 -1
  12. package/dist/gradio-files.d.ts.map +1 -1
  13. package/dist/gradio-files.js +2 -35
  14. package/dist/gradio-files.js.map +1 -1
  15. package/dist/hf-api-call.d.ts.map +1 -1
  16. package/dist/hf-api-call.js +7 -7
  17. package/dist/hf-api-call.js.map +1 -1
  18. package/dist/index.browser.d.ts +48 -0
  19. package/dist/index.browser.d.ts.map +1 -0
  20. package/dist/index.browser.js +153 -0
  21. package/dist/index.browser.js.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/jobs/commands/uv-utils.d.ts +0 -3
  27. package/dist/jobs/commands/uv-utils.d.ts.map +1 -1
  28. package/dist/jobs/commands/uv-utils.js +2 -2
  29. package/dist/jobs/commands/uv-utils.js.map +1 -1
  30. package/dist/jobs/jobs-tool.d.ts.map +1 -1
  31. package/dist/jobs/jobs-tool.js +11 -12
  32. package/dist/jobs/jobs-tool.js.map +1 -1
  33. package/dist/jobs/schema-help.d.ts +2 -9
  34. package/dist/jobs/schema-help.d.ts.map +1 -1
  35. package/dist/jobs/schema-help.js +3 -3
  36. package/dist/jobs/schema-help.js.map +1 -1
  37. package/dist/jobs/sse-handler.d.ts +3 -2
  38. package/dist/jobs/sse-handler.d.ts.map +1 -1
  39. package/dist/jobs/sse-handler.js +8 -4
  40. package/dist/jobs/sse-handler.js.map +1 -1
  41. package/dist/jobs/types.d.ts +1 -1
  42. package/dist/logger.d.ts +2 -2
  43. package/dist/logger.d.ts.map +1 -1
  44. package/dist/network/fetch-profile.d.ts +24 -0
  45. package/dist/network/fetch-profile.d.ts.map +1 -0
  46. package/dist/network/fetch-profile.js +80 -0
  47. package/dist/network/fetch-profile.js.map +1 -0
  48. package/dist/network/index.d.ts +5 -0
  49. package/dist/network/index.d.ts.map +1 -0
  50. package/dist/network/index.js +5 -0
  51. package/dist/network/index.js.map +1 -0
  52. package/dist/network/ip-policy.d.ts +6 -0
  53. package/dist/network/ip-policy.d.ts.map +1 -0
  54. package/dist/network/ip-policy.js +166 -0
  55. package/dist/network/ip-policy.js.map +1 -0
  56. package/dist/network/ip-policy.test.d.ts +2 -0
  57. package/dist/network/ip-policy.test.d.ts.map +1 -0
  58. package/dist/network/ip-policy.test.js +26 -0
  59. package/dist/network/ip-policy.test.js.map +1 -0
  60. package/dist/network/safe-fetch.d.ts +16 -0
  61. package/dist/network/safe-fetch.d.ts.map +1 -0
  62. package/dist/network/safe-fetch.js +124 -0
  63. package/dist/network/safe-fetch.js.map +1 -0
  64. package/dist/network/safe-fetch.test.d.ts +2 -0
  65. package/dist/network/safe-fetch.test.d.ts.map +1 -0
  66. package/dist/network/safe-fetch.test.js +136 -0
  67. package/dist/network/safe-fetch.test.js.map +1 -0
  68. package/dist/network/url-policy.d.ts +32 -0
  69. package/dist/network/url-policy.d.ts.map +1 -0
  70. package/dist/network/url-policy.js +230 -0
  71. package/dist/network/url-policy.js.map +1 -0
  72. package/dist/network/url-policy.test.d.ts +2 -0
  73. package/dist/network/url-policy.test.d.ts.map +1 -0
  74. package/dist/network/url-policy.test.js +57 -0
  75. package/dist/network/url-policy.test.js.map +1 -0
  76. package/dist/readme-utils.d.ts.map +1 -1
  77. package/dist/readme-utils.js +3 -4
  78. package/dist/readme-utils.js.map +1 -1
  79. package/dist/space/commands/discover.d.ts +0 -5
  80. package/dist/space/commands/discover.d.ts.map +1 -1
  81. package/dist/space/commands/discover.js +9 -2
  82. package/dist/space/commands/discover.js.map +1 -1
  83. package/dist/space/commands/invoke.js +1 -59
  84. package/dist/space/commands/invoke.js.map +1 -1
  85. package/dist/space/commands/view-parameters.d.ts.map +1 -1
  86. package/dist/space/commands/view-parameters.js +3 -98
  87. package/dist/space/commands/view-parameters.js.map +1 -1
  88. package/dist/space/dynamic-space-tool.d.ts.map +1 -1
  89. package/dist/space/dynamic-space-tool.js +5 -2
  90. package/dist/space/dynamic-space-tool.js.map +1 -1
  91. package/dist/space/utils/gradio-caller.d.ts.map +1 -1
  92. package/dist/space/utils/gradio-caller.js +13 -6
  93. package/dist/space/utils/gradio-caller.js.map +1 -1
  94. package/dist/space/utils/space-http.d.ts +8 -0
  95. package/dist/space/utils/space-http.d.ts.map +1 -0
  96. package/dist/space/utils/space-http.js +49 -0
  97. package/dist/space/utils/space-http.js.map +1 -0
  98. package/dist/space-files.d.ts +0 -1
  99. package/dist/space-files.d.ts.map +1 -1
  100. package/dist/space-files.js +3 -36
  101. package/dist/space-files.js.map +1 -1
  102. package/package.json +6 -2
  103. package/src/docs-search/doc-fetch.test.ts +98 -28
  104. package/src/docs-search/doc-fetch.ts +9 -16
  105. package/src/file-icons.ts +39 -0
  106. package/src/gradio-files.ts +2 -40
  107. package/src/hf-api-call.ts +8 -10
  108. package/src/index.browser.ts +183 -0
  109. package/src/index.ts +1 -0
  110. package/src/jobs/commands/uv-utils.ts +2 -2
  111. package/src/jobs/jobs-tool.ts +13 -12
  112. package/src/jobs/schema-help.ts +4 -4
  113. package/src/jobs/sse-handler.ts +12 -7
  114. package/src/logger.ts +2 -2
  115. package/src/network/fetch-profile.ts +112 -0
  116. package/src/network/index.ts +4 -0
  117. package/src/network/ip-policy.test.ts +29 -0
  118. package/src/network/ip-policy.ts +206 -0
  119. package/src/network/safe-fetch.test.ts +181 -0
  120. package/src/network/safe-fetch.ts +174 -0
  121. package/src/network/url-policy.test.ts +100 -0
  122. package/src/network/url-policy.ts +304 -0
  123. package/src/readme-utils.ts +11 -10
  124. package/src/space/commands/discover.ts +10 -2
  125. package/src/space/commands/invoke.ts +1 -88
  126. package/src/space/commands/view-parameters.ts +3 -136
  127. package/src/space/dynamic-space-tool.ts +6 -2
  128. package/src/space/utils/gradio-caller.ts +25 -12
  129. package/src/space/utils/space-http.ts +75 -0
  130. package/src/space-files.ts +3 -41
  131. package/test/fetch-guard.spec.ts +70 -0
  132. package/test/jobs/sse-handler.spec.ts +60 -0
  133. package/dist/space/utils/result-formatter.d.ts +0 -4
  134. package/dist/space/utils/result-formatter.d.ts.map +0 -1
  135. package/dist/space/utils/result-formatter.js +0 -146
  136. package/dist/space/utils/result-formatter.js.map +0 -1
  137. package/src/space/utils/result-formatter.ts +0 -226
@@ -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
+ });