fix(proxy): correct registry auth and rewrite lengths
This commit is contained in:
1 parent
87b7529628
commit
805b4d0f6d
3 files changed
+232
-62
No files matched your search
+28
-31
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user