From 805b4d0f6da67bfabb061a555634811ee9e0b1ba Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Fri, 6 Mar 2026 20:27:43 +0800 Subject: [PATCH] fix(proxy): correct registry auth and rewrite lengths --- src/index.js | 59 ++++++++------- src/protocols/docker.js | 97 +++++++++++++++++-------- test/unit/protocols.test.js | 138 +++++++++++++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 62 deletions(-) diff --git a/src/index.js b/src/index.js index cd961aa..077b5eb 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,7 @@ import { fetchToken, getScopeFromUrl, handleDockerAuth, + normalizeRegistryApiPath, parseAuthenticate, responseUnauthorized } from './protocols/docker.js'; @@ -110,13 +111,9 @@ async function handleRequest(request, env, ctx) { // Transform URL based on platform using unified logic const targetPath = transformPath(effectivePath, platform); - // For container registries, ensure we add the /v2 prefix for the Docker API - let finalTargetPath; - if (platform.startsWith('cr-')) { - finalTargetPath = `/v2${targetPath}`; - } else { - finalTargetPath = targetPath; - } + const finalTargetPath = platform.startsWith('cr-') + ? normalizeRegistryApiPath(platform, targetPath) + : targetPath; const targetUrl = `${config.PLATFORMS[platform]}${finalTargetPath}${url.search}`; const authorization = request.headers.get('Authorization'); @@ -438,7 +435,7 @@ async function handleRequest(request, env, ctx) { } } - response = responseUnauthorized(url); + response = responseUnauthorized(url, platform); break; } @@ -482,18 +479,20 @@ async function handleRequest(request, env, ctx) { ); } else if (!response.ok && response.status !== 206) { if (isDocker && response.status === 401) { - // Handle Docker 401 responses that might not have been caught by the retry loop - const isCustomError = - response.headers.get('content-type') === 'application/json' && - (await response.clone().text()).includes('UNAUTHORIZED'); + if (!response.headers.has('WWW-Authenticate')) { + // Handle Docker 401 responses that might not have been caught by the retry loop + const isCustomError = + response.headers.get('content-type') === 'application/json' && + (await response.clone().text()).includes('UNAUTHORIZED'); - if (!isCustomError) { - const errorText = await response.text().catch(() => ''); - response = createErrorResponse( - `Authentication required for this container registry resource. This may be a private repository. Original error: ${errorText}`, - 401, - true - ); + if (!isCustomError) { + const errorText = await response.text().catch(() => ''); + response = createErrorResponse( + `Authentication required for this container registry resource. This may be a private repository. Original error: ${errorText}`, + 401, + true + ); + } } } else { const errorText = await response.text().catch(() => 'Unknown error'); @@ -505,7 +504,9 @@ async function handleRequest(request, env, ctx) { } } else { // Success case processing (rewriting URLs etc) + /** @type {string | ReadableStream | null} */ let responseBody = response.body; + let rewrittenContentLength = null; if ( platform === 'pypi' && @@ -516,12 +517,8 @@ async function handleRequest(request, env, ctx) { /https:\/\/files\.pythonhosted\.org/g, `${url.origin}/pypi/files` ); - responseBody = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(rewrittenText)); - controller.close(); - } - }); + responseBody = rewrittenText; + rewrittenContentLength = new TextEncoder().encode(rewrittenText).byteLength; } if ( @@ -533,16 +530,16 @@ async function handleRequest(request, env, ctx) { /https:\/\/registry.npmjs.org\/([^/]+)/g, `${url.origin}/npm/$1` ); - responseBody = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(rewrittenText)); - controller.close(); - } - }); + responseBody = rewrittenText; + rewrittenContentLength = new TextEncoder().encode(rewrittenText).byteLength; } const headers = new Headers(response.headers); + if (rewrittenContentLength !== null) { + headers.set('Content-Length', String(rewrittenContentLength)); + } + if (!isGit && !isGitLFS && !isDocker && !isAI && !isHF) { if (hasSensitiveHeaders) { headers.set('Cache-Control', 'private, no-store'); diff --git a/src/protocols/docker.js b/src/protocols/docker.js index f066252..d7f5aba 100644 --- a/src/protocols/docker.js +++ b/src/protocols/docker.js @@ -87,41 +87,27 @@ export async function fetchToken(wwwAuthenticate, scope, authorization) { * - "" (empty string) if scope cannot be determined */ export function getScopeFromUrl(url, effectivePath, platform) { - // Infer scope from the request path for container registry requests - let scope = ''; - const pathParts = url.pathname.split('/'); + void url; + const platformPrefix = `/${platform.replace(/-/g, '/')}/`; // Check for catalog endpoint - if (pathParts.includes('_catalog')) { + if (effectivePath.includes('/_catalog')) { return 'registry:catalog:*'; } - if (pathParts.length >= 4 && pathParts[1] === 'v2') { - const platformPrefix = `/${platform.replace(/-/g, '/')}/`; - if (effectivePath.startsWith(platformPrefix)) { - const repoPathFull = effectivePath.slice(platformPrefix.length); - const repoParts = repoPathFull.split('/'); - if (repoParts.length >= 1) { - // Remove /manifests/tag or /blobs/sha suffix to get repo name - // Common suffixes in v2 API: /manifests/, /blobs/, /tags/ - const suffixIndex = repoParts.findIndex(p => - ['manifests', 'blobs', 'tags', 'referrers'].includes(p) - ); + const apiPath = normalizeRegistryApiPath( + platform, + effectivePath.startsWith(platformPrefix) + ? `/${effectivePath.slice(platformPrefix.length)}` + : effectivePath + ); + const repoName = extractRepositoryPath(apiPath); - let repoName = - suffixIndex !== -1 ? repoParts.slice(0, suffixIndex).join('/') : repoParts.join('/'); - - if (platform === 'cr-docker' && repoName && !repoName.includes('/')) { - repoName = `library/${repoName}`; - } - - if (repoName) { - scope = `repository:${repoName}:pull`; - } - } - } + if (repoName) { + return `repository:${repoName}:pull`; } - return scope; + + return ''; } /** @@ -138,6 +124,51 @@ function normalizeRepoPath(platformKey, repoPath) { return repoPath; } +/** + * Extracts the repository path from a Docker registry API path. + * @param {string} apiPath + * @returns {string} Repository path without the `/v2/` prefix or operation suffix. + */ +function extractRepositoryPath(apiPath) { + const normalizedPath = apiPath.startsWith('/v2/') ? apiPath.slice(4) : apiPath.replace(/^\/+/, ''); + const pathParts = normalizedPath.split('/').filter(Boolean); + + if (pathParts.length === 0 || pathParts[0].startsWith('_')) { + return ''; + } + + const suffixIndex = pathParts.findIndex(part => + ['manifests', 'blobs', 'tags', 'referrers'].includes(part) + ); + + if (suffixIndex <= 0) { + return ''; + } + + return pathParts.slice(0, suffixIndex).join('/'); +} + +/** + * Normalizes a Docker registry API path for upstream compatibility. + * @param {string} platformKey + * @param {string} apiPath + * @returns {string} Upstream API path with any registry-specific normalization applied. + */ +export function normalizeRegistryApiPath(platformKey, apiPath) { + if (platformKey !== 'cr-docker' || !apiPath.startsWith('/v2/')) { + return apiPath; + } + + const repoPath = extractRepositoryPath(apiPath); + const normalizedRepoPath = normalizeRepoPath(platformKey, repoPath); + + if (!repoPath || normalizedRepoPath === repoPath) { + return apiPath; + } + + return apiPath.replace(`/v2/${repoPath}`, `/v2/${normalizedRepoPath}`); +} + /** * Resolves the target registry and scope for Docker auth proxy requests. * @param {URL} url @@ -190,11 +221,17 @@ function resolveDockerAuthTarget(url, platforms) { * Generates a Docker/OCI registry-compliant 401 response with a WWW-Authenticate * header that directs clients to the token authentication endpoint. * @param {URL} url - Request URL used to construct authentication realm + * @param {string} platform - Registry platform key (e.g. cr-ghcr) * @returns {Response} Unauthorized response with WWW-Authenticate header */ -export function responseUnauthorized(url) { +export function responseUnauthorized(url, platform) { + const realmPath = platform ? `/cr/${platform.slice(3)}/v2/auth` : '/v2/auth'; const headers = new Headers(); - headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`); + headers.set('Content-Type', 'application/json'); + headers.set( + 'WWW-Authenticate', + `Bearer realm="${url.origin}${realmPath}",service="Xget"` + ); return new Response( JSON.stringify({ errors: [ diff --git a/test/unit/protocols.test.js b/test/unit/protocols.test.js index ff5d89e..2d50c15 100644 --- a/test/unit/protocols.test.js +++ b/test/unit/protocols.test.js @@ -2,7 +2,7 @@ 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 { getScopeFromUrl, handleDockerAuth } from '../../src/protocols/docker.js'; import { isDockerRequest } from '../../src/utils/validation.js'; /** @type {ExecutionContext} */ @@ -38,6 +38,12 @@ describe('Docker Authentication', () => { vi.restoreAllMocks(); }); + it('derives scoped pull access from /cr-prefixed registry requests', () => { + const url = new URL('https://example.com/cr/docker/v2/nginx/manifests/latest'); + + expect(getScopeFromUrl(url, url.pathname, 'cr-docker')).toBe('repository:library/nginx:pull'); + }); + it('normalizes Docker Hub official image scopes during auth proxying', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { const url = String(input); @@ -96,6 +102,106 @@ describe('Docker Authentication', () => { expect(response.status).toBe(200); expect(upstreamCalls[0]).toBe('https://ghcr.io/v2/'); }); + + it('routes registry manifests without duplicating /v2', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { + status: 200, + headers: { 'Content-Length': '0' } + }) + ); + + const request = new Request( + 'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest', + { + method: 'HEAD' + } + ); + const response = await worker.fetch(request, {}, executionContext); + + expect(response.status).toBe(200); + expect(String(fetchSpy.mock.calls[0][0])).toBe( + 'https://ghcr.io/v2/nginxinc/nginx-unprivileged/manifests/latest' + ); + }); + + it('normalizes Docker Hub official image paths during proxying', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { + status: 200, + headers: { 'Content-Length': '0' } + }) + ); + + const request = new Request('https://example.com/cr/docker/v2/nginx/manifests/latest', { + headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' } + }); + const response = await worker.fetch(request, {}, executionContext); + + expect(response.status).toBe(200); + expect(String(fetchSpy.mock.calls[0][0])).toBe( + 'https://registry-1.docker.io/v2/library/nginx/manifests/latest' + ); + }); + + it('preserves platform-specific Docker auth challenges', async () => { + let callCount = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + callCount++; + + if (callCount === 1) { + return new Response('', { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' + } + }); + } + + return new Response('denied', { status: 401 }); + }); + + const request = new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', { + headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' } + }); + const response = await worker.fetch(request, {}, executionContext); + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer realm="https://example.com/cr/ghcr/v2/auth",service="Xget"' + ); + expect(await response.text()).toContain('UNAUTHORIZED'); + }); + + it('accepts standard repository scopes on platform-prefixed auth endpoints', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + const url = String(input); + + if (url === '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?scope=repository:private/repo:pull&service=Xget' + ); + const response = await worker.fetch(request, {}, executionContext); + + expect(response.status).toBe(200); + expect(String(fetchSpy.mock.calls[1][0])).toContain( + 'scope=repository%3Aprivate%2Frepo%3Apull' + ); + }); }); describe('Protocol Header Configuration', () => { @@ -132,4 +238,34 @@ describe('Protocol Header Configuration', () => { userAgent: 'Xget-AI-Proxy/1.0' }); }); + + it('updates Content-Length after rewriting npm metadata', async () => { + const upstreamBody = JSON.stringify({ + dist: { + tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz' + } + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(upstreamBody, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(upstreamBody.length) + } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/npm/pkg'), + {}, + executionContext + ); + const body = await response.text(); + + expect(body).toContain('https://example.com/npm/pkg/-/pkg-1.0.0.tgz'); + expect(response.headers.get('Content-Length')).toBe( + String(new TextEncoder().encode(body).byteLength) + ); + }); });