fix(proxy): tighten protocol routing and stabilize tests
This commit is contained in:
1 parent
09c50c136f
commit
0837de2f14
9 files changed
+262
-149
No files matched your search
+4
-2
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user