@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.
- package/dist/docs-search/doc-fetch.d.ts +1 -0
- package/dist/docs-search/doc-fetch.d.ts.map +1 -1
- package/dist/docs-search/doc-fetch.js +9 -12
- package/dist/docs-search/doc-fetch.js.map +1 -1
- package/dist/docs-search/doc-fetch.test.js +56 -11
- package/dist/docs-search/doc-fetch.test.js.map +1 -1
- package/dist/file-icons.d.ts +3 -0
- package/dist/file-icons.d.ts.map +1 -0
- package/dist/file-icons.js +38 -0
- package/dist/file-icons.js.map +1 -0
- package/dist/gradio-files.d.ts +0 -1
- package/dist/gradio-files.d.ts.map +1 -1
- package/dist/gradio-files.js +2 -35
- package/dist/gradio-files.js.map +1 -1
- package/dist/hf-api-call.d.ts.map +1 -1
- package/dist/hf-api-call.js +7 -7
- package/dist/hf-api-call.js.map +1 -1
- package/dist/index.browser.d.ts +48 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +153 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jobs/commands/uv-utils.d.ts +0 -3
- package/dist/jobs/commands/uv-utils.d.ts.map +1 -1
- package/dist/jobs/commands/uv-utils.js +2 -2
- package/dist/jobs/commands/uv-utils.js.map +1 -1
- package/dist/jobs/jobs-tool.d.ts.map +1 -1
- package/dist/jobs/jobs-tool.js +11 -12
- package/dist/jobs/jobs-tool.js.map +1 -1
- package/dist/jobs/schema-help.d.ts +2 -9
- package/dist/jobs/schema-help.d.ts.map +1 -1
- package/dist/jobs/schema-help.js +3 -3
- package/dist/jobs/schema-help.js.map +1 -1
- package/dist/jobs/sse-handler.d.ts +3 -2
- package/dist/jobs/sse-handler.d.ts.map +1 -1
- package/dist/jobs/sse-handler.js +8 -4
- package/dist/jobs/sse-handler.js.map +1 -1
- package/dist/jobs/types.d.ts +1 -1
- package/dist/logger.d.ts +2 -2
- package/dist/logger.d.ts.map +1 -1
- package/dist/network/fetch-profile.d.ts +24 -0
- package/dist/network/fetch-profile.d.ts.map +1 -0
- package/dist/network/fetch-profile.js +80 -0
- package/dist/network/fetch-profile.js.map +1 -0
- package/dist/network/index.d.ts +5 -0
- package/dist/network/index.d.ts.map +1 -0
- package/dist/network/index.js +5 -0
- package/dist/network/index.js.map +1 -0
- package/dist/network/ip-policy.d.ts +6 -0
- package/dist/network/ip-policy.d.ts.map +1 -0
- package/dist/network/ip-policy.js +202 -0
- package/dist/network/ip-policy.js.map +1 -0
- package/dist/network/ip-policy.test.d.ts +2 -0
- package/dist/network/ip-policy.test.d.ts.map +1 -0
- package/dist/network/ip-policy.test.js +46 -0
- package/dist/network/ip-policy.test.js.map +1 -0
- package/dist/network/safe-fetch.d.ts +16 -0
- package/dist/network/safe-fetch.d.ts.map +1 -0
- package/dist/network/safe-fetch.js +124 -0
- package/dist/network/safe-fetch.js.map +1 -0
- package/dist/network/safe-fetch.test.d.ts +2 -0
- package/dist/network/safe-fetch.test.d.ts.map +1 -0
- package/dist/network/safe-fetch.test.js +136 -0
- package/dist/network/safe-fetch.test.js.map +1 -0
- package/dist/network/url-policy.d.ts +32 -0
- package/dist/network/url-policy.d.ts.map +1 -0
- package/dist/network/url-policy.js +230 -0
- package/dist/network/url-policy.js.map +1 -0
- package/dist/network/url-policy.test.d.ts +2 -0
- package/dist/network/url-policy.test.d.ts.map +1 -0
- package/dist/network/url-policy.test.js +57 -0
- package/dist/network/url-policy.test.js.map +1 -0
- package/dist/readme-utils.d.ts.map +1 -1
- package/dist/readme-utils.js +3 -4
- package/dist/readme-utils.js.map +1 -1
- package/dist/space/commands/discover.d.ts +0 -5
- package/dist/space/commands/discover.d.ts.map +1 -1
- package/dist/space/commands/discover.js +9 -2
- package/dist/space/commands/discover.js.map +1 -1
- package/dist/space/commands/invoke.js +1 -59
- package/dist/space/commands/invoke.js.map +1 -1
- package/dist/space/commands/view-parameters.d.ts.map +1 -1
- package/dist/space/commands/view-parameters.js +3 -98
- package/dist/space/commands/view-parameters.js.map +1 -1
- package/dist/space/dynamic-space-tool.d.ts.map +1 -1
- package/dist/space/dynamic-space-tool.js +5 -2
- package/dist/space/dynamic-space-tool.js.map +1 -1
- package/dist/space/utils/gradio-caller.d.ts.map +1 -1
- package/dist/space/utils/gradio-caller.js +13 -6
- package/dist/space/utils/gradio-caller.js.map +1 -1
- package/dist/space/utils/space-http.d.ts +8 -0
- package/dist/space/utils/space-http.d.ts.map +1 -0
- package/dist/space/utils/space-http.js +49 -0
- package/dist/space/utils/space-http.js.map +1 -0
- package/dist/space-files.d.ts +0 -1
- package/dist/space-files.d.ts.map +1 -1
- package/dist/space-files.js +3 -36
- package/dist/space-files.js.map +1 -1
- package/package.json +6 -2
- package/src/docs-search/doc-fetch.test.ts +98 -28
- package/src/docs-search/doc-fetch.ts +9 -16
- package/src/file-icons.ts +39 -0
- package/src/gradio-files.ts +2 -40
- package/src/hf-api-call.ts +8 -10
- package/src/index.browser.ts +183 -0
- package/src/index.ts +1 -0
- package/src/jobs/commands/uv-utils.ts +2 -2
- package/src/jobs/jobs-tool.ts +13 -12
- package/src/jobs/schema-help.ts +4 -4
- package/src/jobs/sse-handler.ts +12 -7
- package/src/logger.ts +2 -2
- package/src/network/fetch-profile.ts +112 -0
- package/src/network/index.ts +4 -0
- package/src/network/ip-policy.test.ts +58 -0
- package/src/network/ip-policy.ts +252 -0
- package/src/network/safe-fetch.test.ts +181 -0
- package/src/network/safe-fetch.ts +174 -0
- package/src/network/url-policy.test.ts +100 -0
- package/src/network/url-policy.ts +304 -0
- package/src/readme-utils.ts +11 -10
- package/src/space/commands/discover.ts +10 -2
- package/src/space/commands/invoke.ts +1 -88
- package/src/space/commands/view-parameters.ts +3 -136
- package/src/space/dynamic-space-tool.ts +6 -2
- package/src/space/utils/gradio-caller.ts +25 -12
- package/src/space/utils/space-http.ts +75 -0
- package/src/space-files.ts +3 -41
- package/test/fetch-guard.spec.ts +70 -0
- package/test/jobs/sse-handler.spec.ts +60 -0
- package/dist/space/utils/result-formatter.d.ts +0 -4
- package/dist/space/utils/result-formatter.d.ts.map +0 -1
- package/dist/space/utils/result-formatter.js +0 -146
- package/dist/space/utils/result-formatter.js.map +0 -1
- package/src/space/utils/result-formatter.ts +0 -226
package/src/jobs/schema-help.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
|
|
8
8
|
export type AnyZodType = z.ZodType<unknown, z.ZodTypeDef, unknown>;
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
function formatDefaultValue(value: unknown): string | undefined {
|
|
155
155
|
if (value === undefined) {
|
|
156
156
|
return undefined;
|
|
157
157
|
}
|
package/src/jobs/sse-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
144
|
-
|
|
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
|
-
|
|
1
|
+
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
2
2
|
|
|
3
|
-
|
|
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,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
|
+
}
|