fix(proxy): correct registry auth and rewrite lengths

This commit is contained in:
xixu-me committed 2026-03-06 20:27:43 +08:00
1 parent 87b7529628
commit 805b4d0f6d
3 files changed
+232 -62

No files matched your search

+28 -31
View File
@@ -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<Uint8Array> | 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');
+67 -30
View File
@@ -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: [
+137 -1
View File
@@ -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)
);
});
});