@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { assertExternalAddress } from './ip-policy.js';
|
|
2
|
+
import { parseAndValidateUrl, type UrlPolicy } from './url-policy.js';
|
|
3
|
+
|
|
4
|
+
export interface SafeFetchOptions {
|
|
5
|
+
urlPolicy: UrlPolicy;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
maxRedirects?: number;
|
|
8
|
+
externalOnly?: boolean;
|
|
9
|
+
requestInit?: RequestInit;
|
|
10
|
+
stripSensitiveHeadersOnCrossHostRedirect?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SafeFetchResult {
|
|
14
|
+
response: Response;
|
|
15
|
+
finalUrl: URL;
|
|
16
|
+
redirectsFollowed: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 12500;
|
|
20
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
21
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
22
|
+
const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'x-hf-authorization']);
|
|
23
|
+
|
|
24
|
+
function isRedirectStatus(status: number): boolean {
|
|
25
|
+
return REDIRECT_STATUSES.has(status);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dropSensitiveHeaders(headersInit: HeadersInit | undefined): Headers {
|
|
29
|
+
const headers = new Headers(headersInit);
|
|
30
|
+
for (const key of SENSITIVE_HEADERS) {
|
|
31
|
+
headers.delete(key);
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function withMethodAndBody(requestInit: RequestInit, method: string, body: BodyInit | null | undefined): RequestInit {
|
|
37
|
+
const nextInit: RequestInit = {
|
|
38
|
+
...requestInit,
|
|
39
|
+
method,
|
|
40
|
+
redirect: 'manual',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') {
|
|
44
|
+
nextInit.body = body;
|
|
45
|
+
} else {
|
|
46
|
+
delete nextInit.body;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return nextInit;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchWithTimeout(url: URL, requestInit: RequestInit, timeoutMs: number): Promise<Response> {
|
|
53
|
+
if (timeoutMs <= 0) {
|
|
54
|
+
return fetch(url.toString(), {
|
|
55
|
+
...requestInit,
|
|
56
|
+
redirect: 'manual',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const outerSignal = requestInit.signal;
|
|
61
|
+
|
|
62
|
+
if (outerSignal) {
|
|
63
|
+
if (outerSignal.aborted) {
|
|
64
|
+
throw new Error('Request was aborted');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
69
|
+
const signal = outerSignal ? AbortSignal.any([outerSignal, timeoutSignal]) : timeoutSignal;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await fetch(url.toString(), {
|
|
73
|
+
...requestInit,
|
|
74
|
+
signal,
|
|
75
|
+
redirect: 'manual',
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
79
|
+
if (outerSignal?.aborted) {
|
|
80
|
+
throw new Error('Request was aborted');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (timeoutSignal.aborted) {
|
|
84
|
+
throw new Error(`Request timed out after ${timeoutMs.toString()}ms`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(`Request timed out after ${timeoutMs.toString()}ms`);
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function safeFetch(url: string | URL, options: SafeFetchOptions): Promise<SafeFetchResult> {
|
|
94
|
+
const {
|
|
95
|
+
urlPolicy,
|
|
96
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
97
|
+
maxRedirects = DEFAULT_MAX_REDIRECTS,
|
|
98
|
+
externalOnly = false,
|
|
99
|
+
requestInit = {},
|
|
100
|
+
stripSensitiveHeadersOnCrossHostRedirect = true,
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
if (maxRedirects < 0) {
|
|
104
|
+
throw new Error('maxRedirects must be >= 0');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let currentUrl = parseAndValidateUrl(url, urlPolicy);
|
|
108
|
+
if (externalOnly) {
|
|
109
|
+
await assertExternalAddress(currentUrl.hostname);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const baseHeaders = new Headers(requestInit.headers);
|
|
113
|
+
let currentMethod = (requestInit.method || 'GET').toUpperCase();
|
|
114
|
+
let currentBody = requestInit.body;
|
|
115
|
+
let redirectsFollowed = 0;
|
|
116
|
+
|
|
117
|
+
while (true) {
|
|
118
|
+
const currentInit = withMethodAndBody(
|
|
119
|
+
{
|
|
120
|
+
...requestInit,
|
|
121
|
+
headers: baseHeaders,
|
|
122
|
+
},
|
|
123
|
+
currentMethod,
|
|
124
|
+
currentBody
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const response = await fetchWithTimeout(currentUrl, currentInit, timeoutMs);
|
|
128
|
+
|
|
129
|
+
if (!isRedirectStatus(response.status)) {
|
|
130
|
+
return {
|
|
131
|
+
response,
|
|
132
|
+
finalUrl: currentUrl,
|
|
133
|
+
redirectsFollowed,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (redirectsFollowed >= maxRedirects) {
|
|
138
|
+
throw new Error(`Redirect limit exceeded (${maxRedirects.toString()})`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const location = response.headers.get('location');
|
|
142
|
+
if (!location) {
|
|
143
|
+
throw new Error(`Redirect response missing Location header (status ${response.status.toString()})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const nextCandidate = new URL(location, currentUrl);
|
|
147
|
+
const nextUrl = parseAndValidateUrl(nextCandidate, urlPolicy);
|
|
148
|
+
if (externalOnly) {
|
|
149
|
+
await assertExternalAddress(nextUrl.hostname);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (stripSensitiveHeadersOnCrossHostRedirect && currentUrl.origin !== nextUrl.origin) {
|
|
153
|
+
const filtered = dropSensitiveHeaders(baseHeaders);
|
|
154
|
+
baseHeaders.forEach((_, key) => {
|
|
155
|
+
baseHeaders.delete(key);
|
|
156
|
+
});
|
|
157
|
+
filtered.forEach((value, key) => {
|
|
158
|
+
baseHeaders.set(key, value);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (response.status === 303 || ((response.status === 301 || response.status === 302) && currentMethod === 'POST')) {
|
|
163
|
+
currentMethod = 'GET';
|
|
164
|
+
currentBody = undefined;
|
|
165
|
+
baseHeaders.delete('content-length');
|
|
166
|
+
baseHeaders.delete('content-type');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
redirectsFollowed += 1;
|
|
170
|
+
currentUrl = nextUrl;
|
|
171
|
+
|
|
172
|
+
await response.body?.cancel();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createExactHostPolicy,
|
|
4
|
+
createExternalHttpsPolicy,
|
|
5
|
+
createGradioMcpHostPolicy,
|
|
6
|
+
createGradioSchemaHostPolicy,
|
|
7
|
+
createHuggingFaceHubPolicy,
|
|
8
|
+
createLocalhostHttpPolicy,
|
|
9
|
+
createGradioMcpPolicy,
|
|
10
|
+
createHfDocsPolicy,
|
|
11
|
+
isLocalhostHostname,
|
|
12
|
+
parseAndValidateUrl,
|
|
13
|
+
} from './url-policy.js';
|
|
14
|
+
|
|
15
|
+
describe('url-policy', () => {
|
|
16
|
+
describe('HF docs policy', () => {
|
|
17
|
+
it('accepts valid Hugging Face docs and Gradio docs URLs', () => {
|
|
18
|
+
expect(() => parseAndValidateUrl('https://huggingface.co/docs/transformers', createHfDocsPolicy())).not.toThrow();
|
|
19
|
+
expect(() => parseAndValidateUrl('https://www.huggingface.co/docs/datasets', createHfDocsPolicy())).not.toThrow();
|
|
20
|
+
expect(() => parseAndValidateUrl('https://www.gradio.app/guides', createHfDocsPolicy())).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects non-https or non-allowlisted hosts', () => {
|
|
24
|
+
expect(() => parseAndValidateUrl('http://huggingface.co/docs/transformers', createHfDocsPolicy())).toThrow(
|
|
25
|
+
'URL protocol is not allowed'
|
|
26
|
+
);
|
|
27
|
+
expect(() => parseAndValidateUrl('https://example.com/docs/transformers', createHfDocsPolicy())).toThrow(
|
|
28
|
+
'URL hostname is not allowed'
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects traversal and encoded traversal variants', () => {
|
|
33
|
+
const variants = [
|
|
34
|
+
'https://huggingface.co/docs/../x',
|
|
35
|
+
'https://huggingface.co/docs/%2e%2e/x',
|
|
36
|
+
'https://huggingface.co/docs/%2e%2e%2fx',
|
|
37
|
+
'https://huggingface.co/docs/..%2fx',
|
|
38
|
+
'https://huggingface.co/docs/%2e%2e%5cx',
|
|
39
|
+
'https://huggingface.co/docs/%252e%252e%252fx',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const candidate of variants) {
|
|
43
|
+
expect(() => parseAndValidateUrl(candidate, createHfDocsPolicy())).toThrow();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('enforces /docs/ prefix on HF hosts', () => {
|
|
48
|
+
expect(() => parseAndValidateUrl('https://huggingface.co/models/some-model', createHfDocsPolicy())).toThrow(
|
|
49
|
+
'Hugging Face docs URLs must remain under /docs/'
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('Gradio MCP policy', () => {
|
|
55
|
+
it('accepts mcp endpoint URLs over https', () => {
|
|
56
|
+
expect(() =>
|
|
57
|
+
parseAndValidateUrl('https://demo-space.hf.space/gradio_api/mcp/', createGradioMcpPolicy())
|
|
58
|
+
).not.toThrow();
|
|
59
|
+
expect(() =>
|
|
60
|
+
parseAndValidateUrl('https://fake-mcp.local/gradio_api/mcp/', createGradioMcpPolicy())
|
|
61
|
+
).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects invalid paths', () => {
|
|
65
|
+
expect(() => parseAndValidateUrl('https://demo-space.hf.space/not-mcp', createGradioMcpPolicy())).toThrow(
|
|
66
|
+
'URL path must start with /gradio_api/mcp'
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('external https policy', () => {
|
|
72
|
+
it('rejects credentials in URL', () => {
|
|
73
|
+
expect(() => parseAndValidateUrl('https://user:pass@example.com/file.wav', createExternalHttpsPolicy())).toThrow(
|
|
74
|
+
'URL credentials are not allowed'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('supports huggingface hub and localhost policy helpers', () => {
|
|
80
|
+
expect(() => parseAndValidateUrl('https://huggingface.co/api/models', createHuggingFaceHubPolicy())).not.toThrow();
|
|
81
|
+
expect(() => parseAndValidateUrl('http://localhost:7860/health', createLocalhostHttpPolicy())).not.toThrow();
|
|
82
|
+
expect(() => parseAndValidateUrl('https://example.com/x', createExactHostPolicy('example.com', 'https:'))).not.toThrow();
|
|
83
|
+
expect(() =>
|
|
84
|
+
parseAndValidateUrl(
|
|
85
|
+
'https://demo-space.hf.space/gradio_api/mcp/schema',
|
|
86
|
+
createGradioSchemaHostPolicy('demo-space.hf.space')
|
|
87
|
+
)
|
|
88
|
+
).not.toThrow();
|
|
89
|
+
expect(() =>
|
|
90
|
+
parseAndValidateUrl(
|
|
91
|
+
'https://demo-space.hf.space/gradio_api/mcp/',
|
|
92
|
+
createGradioMcpHostPolicy('demo-space.hf.space', 'https:')
|
|
93
|
+
)
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
expect(isLocalhostHostname('localhost')).toBe(true);
|
|
96
|
+
expect(isLocalhostHostname('127.0.0.1')).toBe(true);
|
|
97
|
+
expect(isLocalhostHostname('[::1]')).toBe(true);
|
|
98
|
+
expect(isLocalhostHostname('example.com')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|