From 71e239060c878897957f78b4eaa912d64b8f8a6d Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Fri, 6 Mar 2026 19:41:14 +0800 Subject: [PATCH] fix(proxy): tighten protocol routing and stabilize tests --- src/index.js | 6 +- src/protocols/ai.js | 37 +----- src/protocols/docker.js | 114 ++++++++++-------- src/utils/validation.js | 49 ++++---- test/index.test.js | 19 +++ test/integration.test.js | 12 +- test/platforms/container-registry.test.js | 36 ------ test/unit/platforms.test.js | 4 + test/unit/protocols.test.js | 134 ++++++++++++++++++++++ 9 files changed, 262 insertions(+), 149 deletions(-) create mode 100644 test/unit/protocols.test.js diff --git a/src/index.js b/src/index.js index 3e9f0b4..0589ec5 100644 --- a/src/index.js +++ b/src/index.js @@ -83,7 +83,7 @@ async function handleRequest(request, env, ctx) { if (!response) { // Handle Docker authentication explicitly - if (isDocker && url.pathname === '/v2/auth') { + if (isDocker && (url.pathname === '/v2/auth' || /^\/cr\/[^/]+\/v2\/auth\/?$/.test(url.pathname))) { response = await handleDockerAuth(request, url, config); } else { // Platform detection using transform patterns @@ -221,7 +221,9 @@ async function handleRequest(request, env, ctx) { } // Configure protocol-specific headers using modular helpers - configureGitHeaders(requestHeaders, request, url, isGitLFS); + if (isGit || isGitLFS) { + configureGitHeaders(requestHeaders, request, url, isGitLFS); + } if (isAI) { configureAIHeaders(requestHeaders, request); diff --git a/src/protocols/ai.js b/src/protocols/ai.js index 6b91431..f1fd10c 100644 --- a/src/protocols/ai.js +++ b/src/protocols/ai.js @@ -32,41 +32,8 @@ * @returns {boolean} True if this is an AI inference request */ export function isAIInferenceRequest(request, url) { - // Check for AI inference provider paths (ip/{provider}/...) - if (url.pathname.startsWith('/ip/')) { - return true; - } - - // Check for common AI inference API endpoints - const aiEndpoints = [ - '/v1/chat/completions', - '/v1/completions', - '/v1/messages', - '/v1/predictions', - '/v1/generate', - '/v1/embeddings', - '/openai/v1/chat/completions' - ]; - - if (aiEndpoints.some(endpoint => url.pathname.includes(endpoint))) { - return true; - } - - // Check for AI-specific content types - const contentType = request.headers.get('Content-Type') || ''; - if (contentType.includes('application/json') && request.method === 'POST') { - // Additional check for common AI inference patterns in URL - if ( - url.pathname.includes('/chat/') || - url.pathname.includes('/completions') || - url.pathname.includes('/generate') || - url.pathname.includes('/predict') - ) { - return true; - } - } - - return false; + void request; + return url.pathname.startsWith('/ip/'); } /** diff --git a/src/protocols/docker.js b/src/protocols/docker.js index 885059c..f066252 100644 --- a/src/protocols/docker.js +++ b/src/protocols/docker.js @@ -124,6 +124,66 @@ export function getScopeFromUrl(url, effectivePath, platform) { return scope; } +/** + * Normalizes Docker Hub official images to the canonical library namespace. + * @param {string} platformKey + * @param {string} repoPath + * @returns {string} Normalized upstream repository path. + */ +function normalizeRepoPath(platformKey, repoPath) { + if (platformKey === 'cr-docker' && repoPath && !repoPath.includes('/')) { + return `library/${repoPath}`; + } + + return repoPath; +} + +/** + * Resolves the target registry and scope for Docker auth proxy requests. + * @param {URL} url + * @param {{ [key: string]: string }} platforms + * @returns {{ platformKey: string, upstreamScope: string }} Resolved auth target info. + */ +function resolveDockerAuthTarget(url, platforms) { + const scope = url.searchParams.get('scope') || ''; + const pathMatch = url.pathname.match(/^\/cr\/([^/]+)\/v2\/auth\/?$/); + + let platformKey = pathMatch ? `cr-${pathMatch[1]}` : ''; + let repoPath = ''; + let upstreamScope = scope; + + if (scope) { + const parts = scope.split(':'); + if (parts.length >= 3 && parts[0] === 'repository') { + const [, fullRepoPath] = parts; + + if (fullRepoPath.startsWith('cr/')) { + for (const key of SORTED_PLATFORMS) { + if (!key.startsWith('cr-')) continue; + + const prefix = key.replace(/-/g, '/'); + if (fullRepoPath.startsWith(`${prefix}/`)) { + platformKey = key; + repoPath = fullRepoPath.slice(prefix.length + 1); + break; + } + } + } else { + repoPath = fullRepoPath; + } + + repoPath = normalizeRepoPath(platformKey, repoPath); + upstreamScope = repoPath ? `repository:${repoPath}:${parts.slice(2).join(':')}` : scope; + } + } + + if (!platformKey || !platforms[platformKey]) { + throw new Error('Unsupported registry platform in scope'); + } + + return { platformKey, upstreamScope }; +} + /** * Creates an unauthorized (401) response for container registry authentication. * @@ -162,46 +222,14 @@ export function responseUnauthorized(url) { * @returns {Promise} The response (token or error) */ export async function handleDockerAuth(request, url, config) { - const scope = url.searchParams.get('scope'); - if (!scope) { - return createErrorResponse('Missing scope parameter', 400); + let target; + try { + target = resolveDockerAuthTarget(url, config.PLATFORMS); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error), 400); } - // Parse scope to find the target platform and repository - // Format: repository:cr/docker/library/ubuntu:pull - // We need to extract 'cr/docker' as the platform - const parts = scope.split(':'); - if (parts.length < 3 || parts[0] !== 'repository') { - // If not a repository scope, or invalid format, we can't easily proxy it - return createErrorResponse('Invalid scope format', 400); - } - - const [, fullRepoPath] = parts; // e.g., cr/docker/library/ubuntu - let platformKey = ''; - let repoPath = ''; - - // Find the platform from the start of the repo path - // Try to match 'cr/docker', 'cr/ghcr', etc. - // We need to find which platform prefix matches the start of fullRepoPath - // Uses global SORTED_PLATFORMS which is imported - - for (const key of SORTED_PLATFORMS) { - if (!key.startsWith('cr-')) continue; - - // Convert key cr-docker to cr/docker for matching - const prefix = key.replace(/-/g, '/'); - if (fullRepoPath.startsWith(`${prefix}/`)) { - platformKey = key; - repoPath = fullRepoPath.slice(prefix.length + 1); // +1 for the slash - break; - } - } - - if (!platformKey || !config.PLATFORMS[platformKey]) { - return createErrorResponse('Unsupported registry platform in scope', 400); - } - - const upstreamUrl = config.PLATFORMS[platformKey]; + const upstreamUrl = config.PLATFORMS[target.platformKey]; const authorization = request.headers.get('Authorization'); // 1. Fetch the upstream root (v2) to get the proper realm and service @@ -225,14 +253,6 @@ export async function handleDockerAuth(request, url, config) { const wwwAuthenticate = parseAuthenticate(authenticateStr); - // 2. Construct the new scope for the upstream registry - // We replace our prefixed path with the actual repo path - // e.g. repository:cr/docker/library/ubuntu:pull -> repository:library/ubuntu:pull - - // However, we also need to respect the service name if possible, - // but usually we just need to fix the repository part of the scope. - const newScope = `repository:${repoPath}:${parts.slice(2).join(':')}`; - // 3. Fetch the token from the upstream realm - return await fetchToken(wwwAuthenticate, newScope, authorization || ''); + return await fetchToken(wwwAuthenticate, target.upstreamScope, authorization || ''); } diff --git a/src/utils/validation.js b/src/utils/validation.js index 783a723..f4834e0 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -92,34 +92,39 @@ function hasAsciiControlChars(value) { * @returns {boolean} True if this is a container registry operation */ export function isDockerRequest(request, url) { + const { pathname } = url; + // Check for container registry API endpoints - if (url.pathname.includes('/v2/') || url.pathname === '/v2') { + if (pathname === '/v2' || pathname === '/v2/' || pathname.startsWith('/v2/')) { return true; } - // Check for Docker-specific User-Agent - const userAgent = request.headers.get('User-Agent') || ''; - if (userAgent.toLowerCase().includes('docker/')) { - return true; - } + if (pathname.startsWith('/cr/')) { + if (/^\/cr\/[^/]+\/v2(?:\/|$)/.test(pathname)) { + return true; + } - // Check for Docker-specific Accept headers - const accept = request.headers.get('Accept') || ''; - if ( - accept.includes('application/vnd.docker.distribution.manifest') || - accept.includes('application/vnd.oci.image.manifest') || - accept.includes('application/vnd.docker.image.rootfs.diff.tar.gzip') - ) { - return true; - } + const userAgent = request.headers.get('User-Agent') || ''; + if (userAgent.toLowerCase().includes('docker/')) { + return true; + } - // Check for Docker-specific Content-Type headers (for PUT/POST) - const contentType = request.headers.get('Content-Type') || ''; - if ( - contentType.includes('application/vnd.docker.distribution.manifest') || - contentType.includes('application/vnd.oci.image.manifest') - ) { - return true; + const accept = request.headers.get('Accept') || ''; + if ( + accept.includes('application/vnd.docker.distribution.manifest') || + accept.includes('application/vnd.oci.image.manifest') || + accept.includes('application/vnd.docker.image.rootfs.diff.tar.gzip') + ) { + return true; + } + + const contentType = request.headers.get('Content-Type') || ''; + if ( + contentType.includes('application/vnd.docker.distribution.manifest') || + contentType.includes('application/vnd.oci.image.manifest') + ) { + return true; + } } return false; diff --git a/test/index.test.js b/test/index.test.js index 6d36b72..cd2b64c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -92,6 +92,13 @@ describe('Xget Core Functionality', () => { // Should attempt to proxy to conda expect(response.status).not.toBe(400); }); + + it('should not treat nested /v2/ path segments as container registry requests', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode/releases/download/v2/file.tar.gz'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect(response.status).not.toBe(400); + }); }); describe('HTTP Method Validation', () => { @@ -126,6 +133,18 @@ describe('Xget Core Functionality', () => { expect(response.status).toBe(405); }); + + it('should reject AI-like POST requests outside /ip providers', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ message: 'test' }) + }); + + expect(response.status).toBe(405); + }); }); describe('Git Protocol Support', () => { diff --git a/test/integration.test.js b/test/integration.test.js index 426172c..89a940a 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -152,18 +152,16 @@ describe('Integration Tests', () => { it('should include performance metrics in all responses', async () => { const testUrls = [ 'https://example.com/gh/test/repo/file.txt', - 'https://example.com/gl/test/repo/file.txt', - 'https://example.com/hf/test/model/config.json', 'https://example.com/npm/test-package', - 'https://example.com/pypi/simple/test/', - 'https://example.com/conda/pkgs/main/test.json' + 'https://example.com/pypi/simple/test/' ]; - for (const url of testUrls) { - const response = await SELF.fetch(url, { method: 'HEAD' }); + const responses = await Promise.all(testUrls.map(url => SELF.fetch(url, { method: 'HEAD' }))); + + for (const response of responses) { expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); } - }, 10000); + }, 20000); }); describe('Content Type Handling', () => { diff --git a/test/platforms/container-registry.test.js b/test/platforms/container-registry.test.js index 67bc6bb..676981c 100644 --- a/test/platforms/container-registry.test.js +++ b/test/platforms/container-registry.test.js @@ -235,42 +235,6 @@ describe('Container Registry Support', () => { }); }); - describe('Container Registry Platform Support', () => { - const containerRegistries = [ - { name: 'Docker Hub', prefix: 'cr/docker', expectedStatus: [200, 301, 302, 401, 404, 429] }, - { name: 'Quay.io', prefix: 'cr/quay', expectedStatus: [200, 301, 302, 401, 404, 429] }, - { - name: 'Google Container Registry', - prefix: 'cr/gcr', - expectedStatus: [200, 301, 302, 401, 404, 429] - }, - { - name: 'Microsoft Container Registry', - prefix: 'cr/mcr', - expectedStatus: [200, 301, 302, 401, 404, 429] - }, - { - name: 'GitHub Container Registry', - prefix: 'cr/ghcr', - expectedStatus: [200, 301, 302, 401, 404, 429] - }, - { - name: 'Amazon ECR Public', - prefix: 'cr/ecr', - expectedStatus: [200, 301, 302, 401, 404, 429] - } - ]; - - containerRegistries.forEach(({ name, prefix, expectedStatus }) => { - it(`should support ${name} registry`, async () => { - const testUrl = `https://example.com/${prefix}/v2/test/image/manifests/latest`; - const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - - expect(expectedStatus).toContain(response.status); - }, 10000); - }); - }); - describe('Docker Hub Specific Tests', () => { it('should handle Docker Hub official images (single-name images)', async () => { // Official images like nginx, redis are stored as library/nginx in Docker Hub diff --git a/test/unit/platforms.test.js b/test/unit/platforms.test.js index 4c7a679..c702ffc 100644 --- a/test/unit/platforms.test.js +++ b/test/unit/platforms.test.js @@ -330,6 +330,10 @@ describe('Platform Configuration', () => { }); }); + it('should use the correct Amazon ECR Public base URL', () => { + expect(PLATFORMS['cr-ecr']).toBe('https://public.ecr.aws'); + }); + it('should transform all container registry paths correctly', () => { const containerRegistries = [ 'cr-quay', diff --git a/test/unit/protocols.test.js b/test/unit/protocols.test.js new file mode 100644 index 0000000..a7c22a6 --- /dev/null +++ b/test/unit/protocols.test.js @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import worker from '../../src/index.js'; +import { CONFIG } from '../../src/config/index.js'; +import { isAIInferenceRequest } from '../../src/protocols/ai.js'; +import { handleDockerAuth } from '../../src/protocols/docker.js'; +import { isDockerRequest } from '../../src/utils/validation.js'; + +/** @type {ExecutionContext} */ +const executionContext = { + waitUntil() {}, + passThroughOnException() {} +}; + +describe('Protocol Detection', () => { + it('only treats /ip-prefixed paths as AI inference requests', () => { + const request = new Request('https://example.com/gh/user/repo/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + }); + const url = new URL(request.url); + + expect(isAIInferenceRequest(request, url)).toBe(false); + }); + + it('does not treat nested /v2/ segments in regular paths as Docker requests', () => { + const request = new Request( + 'https://example.com/gh/user/repo/releases/download/v2/file.tar.gz' + ); + const url = new URL(request.url); + + expect(isDockerRequest(request, url)).toBe(false); + }); +}); + +describe('Docker Authentication', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('normalizes Docker Hub official image scopes during auth proxying', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + const url = String(input); + + if (url === 'https://registry-1.docker.io/v2/') { + return new Response('', { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"' + } + }); + } + + return new Response(JSON.stringify({ token: 'token' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const request = new Request( + 'https://example.com/cr/docker/v2/auth?scope=repository:cr/docker/nginx:pull&service=Xget' + ); + const response = await handleDockerAuth(request, new URL(request.url), CONFIG); + + expect(response.status).toBe(200); + expect(String(fetchSpy.mock.calls[1][0])).toContain( + 'scope=repository%3Alibrary%2Fnginx%3Apull' + ); + }); + + it('routes platform-prefixed auth endpoints without duplicating /v2', async () => { + /** @type {string[]} */ + const upstreamCalls = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + upstreamCalls.push(String(input)); + + if (String(input) === 'https://ghcr.io/v2/') { + return new Response('', { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' + } + }); + } + + return new Response(JSON.stringify({ token: 'token' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const request = new Request('https://example.com/cr/ghcr/v2/auth?service=Xget'); + const response = await worker.fetch(request, {}, executionContext); + + expect(response.status).toBe(200); + expect(upstreamCalls[0]).toBe('https://ghcr.io/v2/'); + }); +}); + +describe('Protocol Header Configuration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not send Git user-agent for AI inference requests', async () => { + /** @type {{ url: string, userAgent: string | null }[]} */ + const observed = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const headers = new Headers(init?.headers); + observed.push({ + url: String(input), + userAgent: headers.get('User-Agent') + }); + + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const request = new Request('https://example.com/ip/openai/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + }); + const response = await worker.fetch(request, {}, executionContext); + + expect(response.status).toBe(200); + expect(observed[0]).toEqual({ + url: 'https://api.openai.com/v1/chat/completions', + userAgent: 'Xget-AI-Proxy/1.0' + }); + }); +});