fix(proxy): tighten protocol routing and stabilize tests

This commit is contained in:
xixu-me committed 2026-03-06 19:41:14 +08:00
1 parent 09c50c136f
commit 0837de2f14
9 files changed
+262 -149

No files matched your search

+4 -2
View File
@@ -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);
+2 -35
View File
@@ -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/');
}
/**
+67 -47
View File
@@ -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<Response>} 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 || '');
}
+27 -22
View File
@@ -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;
+19
View File
@@ -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', () => {
+5 -7
View File
@@ -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', () => {
-36
View File
@@ -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
+4
View File
@@ -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',
+134
View File
@@ -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'
});
});
});