diff --git a/src/index.js b/src/index.js index ee73b5f..ea31a30 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,6 @@ import { fetchToken, getScopeFromUrl, handleDockerAuth, - normalizeRegistryApiPath, parseAuthenticate, responseUnauthorized } from './protocols/docker.js'; @@ -138,9 +137,13 @@ async function handleRequest(request, env, ctx) { // Transform URL based on platform using unified logic const targetPath = transformPath(effectivePath, platform); - const finalTargetPath = platform.startsWith('cr-') - ? normalizeRegistryApiPath(platform, targetPath) - : targetPath; + // 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 targetUrl = `${config.PLATFORMS[platform]}${finalTargetPath}${url.search}`; const authorization = request.headers.get('Authorization'); @@ -460,7 +463,7 @@ async function handleRequest(request, env, ctx) { } } - response = responseUnauthorized(url, platform); + response = responseUnauthorized(url); break; } @@ -504,20 +507,18 @@ async function handleRequest(request, env, ctx) { ); } else if (!response.ok && response.status !== 206) { if (isDocker && response.status === 401) { - 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'); + // 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'); @@ -529,9 +530,7 @@ 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' && @@ -542,8 +541,12 @@ async function handleRequest(request, env, ctx) { /https:\/\/files\.pythonhosted\.org/g, `${url.origin}/pypi/files` ); - responseBody = rewrittenText; - rewrittenContentLength = new TextEncoder().encode(rewrittenText).byteLength; + responseBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(rewrittenText)); + controller.close(); + } + }); } if ( @@ -555,16 +558,16 @@ async function handleRequest(request, env, ctx) { /https:\/\/registry.npmjs.org\/([^/]+)/g, `${url.origin}/npm/$1` ); - responseBody = rewrittenText; - rewrittenContentLength = new TextEncoder().encode(rewrittenText).byteLength; + responseBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(rewrittenText)); + controller.close(); + } + }); } 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 bee3c7b..ec5de62 100644 --- a/src/protocols/docker.js +++ b/src/protocols/docker.js @@ -87,27 +87,41 @@ export async function fetchToken(wwwAuthenticate, scope, authorization) { * - "" (empty string) if scope cannot be determined */ export function getScopeFromUrl(url, effectivePath, platform) { - void url; - const platformPrefix = `/${platform.replace(/-/g, '/')}/`; + // Infer scope from the request path for container registry requests + let scope = ''; + const pathParts = url.pathname.split('/'); // Check for catalog endpoint - if (effectivePath.includes('/_catalog')) { + if (pathParts.includes('_catalog')) { return 'registry:catalog:*'; } - const apiPath = normalizeRegistryApiPath( - platform, - effectivePath.startsWith(platformPrefix) - ? `/${effectivePath.slice(platformPrefix.length)}` - : effectivePath - ); - const repoName = extractRepositoryPath(apiPath); + 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) + ); - if (repoName) { - return `repository:${repoName}:pull`; + 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`; + } + } + } } - - return ''; + return scope; } /** @@ -124,51 +138,6 @@ 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 @@ -221,17 +190,11 @@ 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, platform) { - const realmPath = platform ? `/cr/${platform.slice(3)}/v2/auth` : '/v2/auth'; +export function responseUnauthorized(url) { const headers = new Headers(); - headers.set('Content-Type', 'application/json'); - headers.set( - 'WWW-Authenticate', - `Bearer realm="${url.origin}${realmPath}",service="Xget"` - ); + headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`); return new Response( JSON.stringify({ errors: [ diff --git a/test/unit/protocols.test.js b/test/unit/protocols.test.js index 2d50c15..ff5d89e 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 { getScopeFromUrl, handleDockerAuth } from '../../src/protocols/docker.js'; +import { handleDockerAuth } from '../../src/protocols/docker.js'; import { isDockerRequest } from '../../src/utils/validation.js'; /** @type {ExecutionContext} */ @@ -38,12 +38,6 @@ 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); @@ -102,106 +96,6 @@ 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', () => { @@ -238,34 +132,4 @@ 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) - ); - }); });