@llmindset/hf-mcp 0.3.2 → 0.3.4

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 +202 -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 +46 -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 +58 -0
  118. package/src/network/ip-policy.ts +252 -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,58 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { assertExternalAddress, isIpInternalOrReserved } from './ip-policy.js';
3
+
4
+ const { lookupMock } = vi.hoisted(() => ({
5
+ lookupMock: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('node:dns/promises', () => ({
9
+ lookup: lookupMock,
10
+ }));
11
+
12
+ describe('ip-policy', () => {
13
+ afterEach(() => {
14
+ lookupMock.mockReset();
15
+ delete process.env.ALLOW_INTERNAL_ADDRESS_HOSTS;
16
+ });
17
+
18
+ it('classifies internal/reserved IPv4 ranges', () => {
19
+ expect(isIpInternalOrReserved('127.0.0.1')).toBe(true);
20
+ expect(isIpInternalOrReserved('10.1.2.3')).toBe(true);
21
+ expect(isIpInternalOrReserved('172.20.10.2')).toBe(true);
22
+ expect(isIpInternalOrReserved('192.168.1.1')).toBe(true);
23
+ expect(isIpInternalOrReserved('8.8.8.8')).toBe(false);
24
+ });
25
+
26
+ it('classifies internal/reserved IPv6 ranges', () => {
27
+ expect(isIpInternalOrReserved('::1')).toBe(true);
28
+ expect(isIpInternalOrReserved('fc00::1')).toBe(true);
29
+ expect(isIpInternalOrReserved('fe80::1')).toBe(true);
30
+ expect(isIpInternalOrReserved('2001:db8::1')).toBe(true);
31
+ expect(isIpInternalOrReserved('2607:f8b0:4005:80a::200e')).toBe(false);
32
+ });
33
+
34
+ it('blocks internal literal addresses in assertExternalAddress', async () => {
35
+ await expect(assertExternalAddress('127.0.0.1')).rejects.toThrow('Blocked internal or reserved address');
36
+ await expect(assertExternalAddress('::1')).rejects.toThrow('Blocked internal or reserved address');
37
+ });
38
+
39
+ it('allows external literal addresses in assertExternalAddress', async () => {
40
+ await expect(assertExternalAddress('8.8.8.8')).resolves.toBeUndefined();
41
+ });
42
+
43
+ it('blocks hostnames resolving to internal addresses by default', async () => {
44
+ lookupMock.mockResolvedValue([{ address: '10.0.246.93' }]);
45
+
46
+ await expect(assertExternalAddress('huggingface.co')).rejects.toThrow(
47
+ 'Blocked internal or reserved address for hostname huggingface.co: 10.0.246.93'
48
+ );
49
+ });
50
+
51
+ it('allows allowlisted hostnames to resolve to internal addresses', async () => {
52
+ process.env.ALLOW_INTERNAL_ADDRESS_HOSTS = 'huggingface.co,*.hf.space';
53
+ lookupMock.mockResolvedValue([{ address: '10.0.246.93' }]);
54
+
55
+ await expect(assertExternalAddress('huggingface.co')).resolves.toBeUndefined();
56
+ await expect(assertExternalAddress('demo.hf.space')).resolves.toBeUndefined();
57
+ });
58
+ });
@@ -0,0 +1,252 @@
1
+ export interface ExternalAddressOptions {
2
+ allowDnsRebindMitigation?: boolean;
3
+ }
4
+
5
+ const INTERNAL_ADDRESS_HOST_ALLOWLIST_ENV = 'ALLOW_INTERNAL_ADDRESS_HOSTS';
6
+
7
+ function normalizeHostname(hostname: string): string {
8
+ return hostname.trim().toLowerCase().replace(/\.+$/, '');
9
+ }
10
+
11
+ function getInternalAddressHostAllowlist(): string[] {
12
+ const raw = process.env[INTERNAL_ADDRESS_HOST_ALLOWLIST_ENV];
13
+ if (!raw) {
14
+ return [];
15
+ }
16
+
17
+ return raw
18
+ .split(',')
19
+ .map((entry) => normalizeHostname(entry))
20
+ .filter((entry) => entry.length > 0);
21
+ }
22
+
23
+ function hostnameMatchesPattern(hostname: string, pattern: string): boolean {
24
+ if (pattern.startsWith('*.')) {
25
+ const baseDomain = pattern.slice(2);
26
+ if (!baseDomain) {
27
+ return false;
28
+ }
29
+ return hostname === baseDomain || hostname.endsWith(`.${baseDomain}`);
30
+ }
31
+
32
+ return hostname === pattern;
33
+ }
34
+
35
+ function isInternalAddressAllowedForHostname(hostname: string): boolean {
36
+ const normalizedHostname = normalizeHostname(hostname);
37
+ if (!normalizedHostname) {
38
+ return false;
39
+ }
40
+
41
+ const allowlist = getInternalAddressHostAllowlist();
42
+ if (allowlist.length === 0) {
43
+ return false;
44
+ }
45
+
46
+ return allowlist.some((pattern) => hostnameMatchesPattern(normalizedHostname, pattern));
47
+ }
48
+
49
+ function normalizeIpLiteral(host: string): string {
50
+ if (host.startsWith('[') && host.endsWith(']')) {
51
+ return host.slice(1, -1);
52
+ }
53
+ return host;
54
+ }
55
+
56
+ function parseIpv4ToInt(ip: string): number {
57
+ const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
58
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
59
+ throw new Error(`Invalid IPv4 address: ${ip}`);
60
+ }
61
+
62
+ return parts.reduce((acc, part) => acc * 256 + part, 0);
63
+ }
64
+
65
+ function ipv4InRange(ipValue: number, start: string, end: string): boolean {
66
+ const startValue = parseIpv4ToInt(start);
67
+ const endValue = parseIpv4ToInt(end);
68
+ return ipValue >= startValue && ipValue <= endValue;
69
+ }
70
+
71
+ function isIpv4InternalOrReserved(ip: string): boolean {
72
+ const value = parseIpv4ToInt(ip);
73
+
74
+ return (
75
+ ipv4InRange(value, '0.0.0.0', '0.255.255.255') ||
76
+ ipv4InRange(value, '10.0.0.0', '10.255.255.255') ||
77
+ ipv4InRange(value, '100.64.0.0', '100.127.255.255') ||
78
+ ipv4InRange(value, '127.0.0.0', '127.255.255.255') ||
79
+ ipv4InRange(value, '169.254.0.0', '169.254.255.255') ||
80
+ ipv4InRange(value, '172.16.0.0', '172.31.255.255') ||
81
+ ipv4InRange(value, '192.0.0.0', '192.0.0.255') ||
82
+ ipv4InRange(value, '192.0.2.0', '192.0.2.255') ||
83
+ ipv4InRange(value, '192.88.99.0', '192.88.99.255') ||
84
+ ipv4InRange(value, '192.168.0.0', '192.168.255.255') ||
85
+ ipv4InRange(value, '198.18.0.0', '198.19.255.255') ||
86
+ ipv4InRange(value, '198.51.100.0', '198.51.100.255') ||
87
+ ipv4InRange(value, '203.0.113.0', '203.0.113.255') ||
88
+ ipv4InRange(value, '224.0.0.0', '239.255.255.255') ||
89
+ ipv4InRange(value, '240.0.0.0', '255.255.255.255')
90
+ );
91
+ }
92
+
93
+ function parseIpv6ToBigInt(ip: string): bigint {
94
+ const zoneIndex = ip.indexOf('%');
95
+ const zoneStripped = zoneIndex >= 0 ? ip.slice(0, zoneIndex) : ip;
96
+
97
+ let working = zoneStripped;
98
+ if (working.includes('.')) {
99
+ const lastColon = working.lastIndexOf(':');
100
+ if (lastColon < 0) {
101
+ throw new Error(`Invalid IPv6 address: ${ip}`);
102
+ }
103
+ const ipv4Part = working.slice(lastColon + 1);
104
+ const ipv4Value = parseIpv4ToInt(ipv4Part);
105
+ const high = ((ipv4Value >>> 16) & 0xffff).toString(16);
106
+ const low = (ipv4Value & 0xffff).toString(16);
107
+ working = `${working.slice(0, lastColon)}:${high}:${low}`;
108
+ }
109
+
110
+ const split = working.split('::');
111
+ if (split.length > 2) {
112
+ throw new Error(`Invalid IPv6 address: ${ip}`);
113
+ }
114
+
115
+ const left = split[0] ? split[0].split(':').filter(Boolean) : [];
116
+ const right = split[1] ? split[1].split(':').filter(Boolean) : [];
117
+ const missingCount = 8 - (left.length + right.length);
118
+
119
+ if (split.length === 1 && missingCount !== 0) {
120
+ throw new Error(`Invalid IPv6 address: ${ip}`);
121
+ }
122
+
123
+ if (missingCount < 0) {
124
+ throw new Error(`Invalid IPv6 address: ${ip}`);
125
+ }
126
+
127
+ const full = [...left, ...Array.from({ length: missingCount }, () => '0'), ...right];
128
+ if (full.length !== 8) {
129
+ throw new Error(`Invalid IPv6 address: ${ip}`);
130
+ }
131
+
132
+ let value = 0n;
133
+ for (const part of full) {
134
+ const segment = Number.parseInt(part, 16);
135
+ if (Number.isNaN(segment) || segment < 0 || segment > 0xffff) {
136
+ throw new Error(`Invalid IPv6 address: ${ip}`);
137
+ }
138
+ value = (value << 16n) + BigInt(segment);
139
+ }
140
+
141
+ return value;
142
+ }
143
+
144
+ function isIpv6InCidr(ipValue: bigint, prefixValue: bigint, prefixLength: number): boolean {
145
+ const hostBits = 128n - BigInt(prefixLength);
146
+ const mask = ((1n << BigInt(prefixLength)) - 1n) << hostBits;
147
+ return (ipValue & mask) === (prefixValue & mask);
148
+ }
149
+
150
+ function isIpv6InternalOrReserved(ip: string): boolean {
151
+ const value = parseIpv6ToBigInt(ip);
152
+
153
+ if (value === 0n || value === 1n) {
154
+ return true;
155
+ }
156
+
157
+ // IPv4-mapped IPv6 ::ffff:a.b.c.d
158
+ if (value >> 32n === 0xffffn) {
159
+ const ipv4Value = Number(value & 0xffffffffn);
160
+ const octet1 = (ipv4Value >>> 24) & 0xff;
161
+ const octet2 = (ipv4Value >>> 16) & 0xff;
162
+ const octet3 = (ipv4Value >>> 8) & 0xff;
163
+ const octet4 = ipv4Value & 0xff;
164
+ return isIpv4InternalOrReserved(
165
+ `${octet1.toString()}.${octet2.toString()}.${octet3.toString()}.${octet4.toString()}`
166
+ );
167
+ }
168
+
169
+ return (
170
+ isIpv6InCidr(value, 0xfc00n << 112n, 7) || // Unique local
171
+ isIpv6InCidr(value, 0xfe80n << 112n, 10) || // Link-local
172
+ isIpv6InCidr(value, 0xff00n << 112n, 8) || // Multicast
173
+ isIpv6InCidr(value, 0x20010db8n << 96n, 32) || // Documentation
174
+ isIpv6InCidr(value, 0x20010010n << 96n, 28) // ORCHID
175
+ );
176
+ }
177
+
178
+ export function isIpInternalOrReserved(ip: string): boolean {
179
+ const normalizedIp = normalizeIpLiteral(ip);
180
+ const ipVersion = detectIpVersion(normalizedIp);
181
+ if (ipVersion === 0) {
182
+ throw new Error(`Invalid IP address: ${ip}`);
183
+ }
184
+
185
+ if (ipVersion === 4) {
186
+ return isIpv4InternalOrReserved(normalizedIp);
187
+ }
188
+
189
+ return isIpv6InternalOrReserved(normalizedIp);
190
+ }
191
+
192
+ async function lookupAll(hostname: string): Promise<string[]> {
193
+ const { lookup } = await import('node:dns/promises');
194
+ const results = await lookup(hostname, { all: true, verbatim: true });
195
+ return results.map((entry) => entry.address);
196
+ }
197
+
198
+ function detectIpVersion(candidate: string): 0 | 4 | 6 {
199
+ try {
200
+ parseIpv4ToInt(candidate);
201
+ return 4;
202
+ } catch {
203
+ // continue to ipv6 parsing
204
+ }
205
+
206
+ try {
207
+ parseIpv6ToBigInt(candidate);
208
+ return 6;
209
+ } catch {
210
+ return 0;
211
+ }
212
+ }
213
+
214
+ export async function assertExternalAddress(hostname: string, options: ExternalAddressOptions = {}): Promise<void> {
215
+ const { allowDnsRebindMitigation = true } = options;
216
+ const normalized = normalizeHostname(hostname);
217
+
218
+ if (!normalized) {
219
+ throw new Error('Hostname is required for external address check');
220
+ }
221
+
222
+ const allowInternalAddress = isInternalAddressAllowedForHostname(normalized);
223
+
224
+ const ipLiteral = normalizeIpLiteral(normalized);
225
+ const ipVersion = detectIpVersion(ipLiteral);
226
+ if (ipVersion !== 0) {
227
+ if (isIpInternalOrReserved(ipLiteral)) {
228
+ throw new Error(`Blocked internal or reserved address: ${ipLiteral}`);
229
+ }
230
+ return;
231
+ }
232
+
233
+ const firstLookup = await lookupAll(normalized);
234
+ if (firstLookup.length === 0) {
235
+ throw new Error(`No DNS records found for hostname: ${normalized}`);
236
+ }
237
+
238
+ for (const address of firstLookup) {
239
+ if (isIpInternalOrReserved(address) && !allowInternalAddress) {
240
+ throw new Error(`Blocked internal or reserved address for hostname ${normalized}: ${address}`);
241
+ }
242
+ }
243
+
244
+ if (allowDnsRebindMitigation) {
245
+ const secondLookup = await lookupAll(normalized);
246
+ for (const address of secondLookup) {
247
+ if (isIpInternalOrReserved(address) && !allowInternalAddress) {
248
+ throw new Error(`Blocked internal or reserved address for hostname ${normalized}: ${address}`);
249
+ }
250
+ }
251
+ }
252
+ }