feat: refactor AI inference request detection and Docker authentication handling
This commit is contained in:
1 parent
89dc392374
commit
18247d5cfe
16 files changed
+787
-242
No files matched your search
Generated
+1
-6
@@ -8,8 +8,7 @@
|
||||
"name": "xget",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"xget": "file:"
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.12.18",
|
||||
@@ -6402,10 +6401,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xget": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"xget": "file:"
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
|
||||
+15
-4
@@ -132,16 +132,27 @@ import { PLATFORMS } from './platforms.js';
|
||||
* // ['https://example.com', 'https://app.example.com']
|
||||
*/
|
||||
export function createConfig(env = {}) {
|
||||
const allowedMethods =
|
||||
typeof env.ALLOWED_METHODS === 'string'
|
||||
? env.ALLOWED_METHODS.split(',')
|
||||
.map(method => method.trim())
|
||||
.filter(Boolean)
|
||||
: ['GET', 'HEAD'];
|
||||
const allowedOrigins =
|
||||
typeof env.ALLOWED_ORIGINS === 'string'
|
||||
? env.ALLOWED_ORIGINS.split(',')
|
||||
.map(origin => origin.trim())
|
||||
.filter(Boolean)
|
||||
: ['*'];
|
||||
|
||||
return {
|
||||
TIMEOUT_SECONDS: parseInt(String(env.TIMEOUT_SECONDS), 10) || 30,
|
||||
MAX_RETRIES: parseInt(String(env.MAX_RETRIES), 10) || 3,
|
||||
RETRY_DELAY_MS: parseInt(String(env.RETRY_DELAY_MS), 10) || 1000,
|
||||
CACHE_DURATION: parseInt(String(env.CACHE_DURATION), 10) || 1800, // 30 minutes
|
||||
SECURITY: {
|
||||
ALLOWED_METHODS:
|
||||
typeof env.ALLOWED_METHODS === 'string' ? env.ALLOWED_METHODS.split(',') : ['GET', 'HEAD'],
|
||||
ALLOWED_ORIGINS:
|
||||
typeof env.ALLOWED_ORIGINS === 'string' ? env.ALLOWED_ORIGINS.split(',') : ['*'],
|
||||
ALLOWED_METHODS: allowedMethods.length ? allowedMethods : ['GET', 'HEAD'],
|
||||
ALLOWED_ORIGINS: allowedOrigins.length ? allowedOrigins : ['*'],
|
||||
MAX_PATH_LENGTH: parseInt(String(env.MAX_PATH_LENGTH), 10) || 2048
|
||||
},
|
||||
PLATFORMS
|
||||
|
||||
+84
-44
@@ -16,13 +16,14 @@ import {
|
||||
fetchToken,
|
||||
getScopeFromUrl,
|
||||
handleDockerAuth,
|
||||
normalizeRegistryApiPath,
|
||||
parseAuthenticate,
|
||||
responseUnauthorized
|
||||
} from './protocols/docker.js';
|
||||
import { configureGitHeaders, isGitLFSRequest, isGitRequest } from './protocols/git.js';
|
||||
import { PerformanceMonitor, addPerformanceHeaders } from './utils/performance.js';
|
||||
import { addSecurityHeaders, createErrorResponse } from './utils/security.js';
|
||||
import { isDockerRequest, validateRequest } from './utils/validation.js';
|
||||
import { addCorsHeaders, addSecurityHeaders, createErrorResponse } from './utils/security.js';
|
||||
import { getAllowedMethods, isDockerRequest, validateRequest } from './utils/validation.js';
|
||||
|
||||
/**
|
||||
* Main request handler with comprehensive caching, retry logic, and security measures.
|
||||
@@ -40,9 +41,36 @@ async function handleRequest(request, env, ctx) {
|
||||
const config = env ? createConfig(env) : CONFIG;
|
||||
const url = new URL(request.url);
|
||||
const isDocker = isDockerRequest(request, url);
|
||||
const isCorsPreflight =
|
||||
request.method === 'OPTIONS' &&
|
||||
request.headers.has('Origin') &&
|
||||
request.headers.has('Access-Control-Request-Method');
|
||||
|
||||
if (isCorsPreflight) {
|
||||
const requestedMethod = request.headers.get('Access-Control-Request-Method') || '';
|
||||
const allowedMethods = getAllowedMethods(
|
||||
new Request(request.url, { method: requestedMethod || 'GET' }),
|
||||
url,
|
||||
config
|
||||
);
|
||||
|
||||
if (!allowedMethods.includes(requestedMethod)) {
|
||||
response = createErrorResponse('Method not allowed', 405);
|
||||
} else {
|
||||
const headers = addCorsHeaders(new Headers(), request, config);
|
||||
if (!headers.has('Access-Control-Allow-Origin')) {
|
||||
response = createErrorResponse('Origin not allowed', 403);
|
||||
} else {
|
||||
headers.set('Access-Control-Allow-Methods', allowedMethods.join(', '));
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
addSecurityHeaders(headers);
|
||||
response = new Response(null, { status: 204, headers });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Docker API version check
|
||||
if (isDocker && (url.pathname === '/v2/' || url.pathname === '/v2')) {
|
||||
else if (isDocker && (url.pathname === '/v2/' || url.pathname === '/v2')) {
|
||||
const headers = new Headers({
|
||||
'Docker-Distribution-Api-Version': 'registry/2.0',
|
||||
'Content-Type': 'application/json'
|
||||
@@ -83,7 +111,10 @@ 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
|
||||
@@ -107,13 +138,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');
|
||||
@@ -221,7 +248,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);
|
||||
@@ -237,11 +266,6 @@ async function handleRequest(request, env, ctx) {
|
||||
http3: true,
|
||||
cacheTtl: config.CACHE_DURATION,
|
||||
cacheEverything: true,
|
||||
minify: {
|
||||
javascript: true,
|
||||
css: true,
|
||||
html: true
|
||||
},
|
||||
preconnect: true
|
||||
}
|
||||
});
|
||||
@@ -249,7 +273,10 @@ async function handleRequest(request, env, ctx) {
|
||||
requestHeaders.set('Accept-Encoding', 'gzip, deflate, br');
|
||||
requestHeaders.set('Connection', 'keep-alive');
|
||||
requestHeaders.set('User-Agent', 'Wget/1.21.3');
|
||||
requestHeaders.set('Origin', request.headers.get('Origin') || '*');
|
||||
const origin = request.headers.get('Origin');
|
||||
if (origin) {
|
||||
requestHeaders.set('Origin', origin);
|
||||
}
|
||||
|
||||
if (authorization) {
|
||||
requestHeaders.set('Authorization', authorization);
|
||||
@@ -433,7 +460,7 @@ async function handleRequest(request, env, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
response = responseUnauthorized(url);
|
||||
response = responseUnauthorized(url, platform);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -477,18 +504,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');
|
||||
@@ -500,7 +529,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' &&
|
||||
@@ -511,12 +542,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 (
|
||||
@@ -528,16 +555,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');
|
||||
@@ -646,9 +673,22 @@ async function handleRequest(request, env, ctx) {
|
||||
const isGitLFS = isGitLFSRequest(request, new URL(request.url));
|
||||
const isHF = isHuggingFaceAPIRequest(request, new URL(request.url));
|
||||
|
||||
const responseWithCors = (() => {
|
||||
const headers = addCorsHeaders(
|
||||
new Headers(response.headers),
|
||||
request,
|
||||
env ? createConfig(env) : CONFIG
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
})();
|
||||
|
||||
return isGit || isGitLFS || isDocker || isAI || isHF
|
||||
? response
|
||||
: addPerformanceHeaders(response, monitor);
|
||||
? responseWithCors
|
||||
: addPerformanceHeaders(responseWithCors, monitor);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
+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/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+132
-73
@@ -87,41 +87,134 @@ 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 (repoName) {
|
||||
return `repository:${repoName}:pull`;
|
||||
}
|
||||
|
||||
if (platform === 'cr-docker' && repoName && !repoName.includes('/')) {
|
||||
repoName = `library/${repoName}`;
|
||||
}
|
||||
|
||||
if (repoName) {
|
||||
scope = `repository:${repoName}:pull`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
|
||||
if (!platformKey || !platforms[platformKey]) {
|
||||
throw new Error('Unsupported registry platform in scope');
|
||||
}
|
||||
|
||||
return { platformKey, upstreamScope };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,11 +223,14 @@ export function getScopeFromUrl(url, effectivePath, platform) {
|
||||
* 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: [
|
||||
@@ -162,46 +258,17 @@ 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) {
|
||||
// Log internal error details server-side without exposing them to the client
|
||||
console.error('Failed to resolve Docker auth target:', error);
|
||||
// Return a generic error response to avoid leaking implementation details
|
||||
return createErrorResponse('Invalid Docker authentication request', 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 +292,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 || '');
|
||||
}
|
||||
@@ -20,6 +20,59 @@
|
||||
* Security utility functions for Xget
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolves the allowed CORS origin for the current request.
|
||||
* @param {Request} request
|
||||
* @param {import('../config/index.js').ApplicationConfig} config
|
||||
* @returns {string | null} Allowed origin value for the response, or null if not allowed.
|
||||
*/
|
||||
export function resolveAllowedOrigin(request, config) {
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedOrigins = config.SECURITY.ALLOWED_ORIGINS;
|
||||
if (allowedOrigins.includes('*')) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
return allowedOrigins.includes(origin) ? origin : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies CORS headers to a response when the request origin is allowed.
|
||||
* @param {Headers} headers
|
||||
* @param {Request} request
|
||||
* @param {import('../config/index.js').ApplicationConfig} config
|
||||
* @returns {Headers} The same headers object with CORS headers applied when permitted.
|
||||
*/
|
||||
export function addCorsHeaders(headers, request, config) {
|
||||
const allowedOrigin = resolveAllowedOrigin(request, config);
|
||||
if (!allowedOrigin) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
headers.set('Access-Control-Allow-Origin', allowedOrigin);
|
||||
headers.set('Access-Control-Allow-Methods', config.SECURITY.ALLOWED_METHODS.join(', '));
|
||||
|
||||
const requestedHeaders = request.headers.get('Access-Control-Request-Headers');
|
||||
if (requestedHeaders) {
|
||||
headers.set('Access-Control-Allow-Headers', requestedHeaders);
|
||||
}
|
||||
|
||||
const vary = new Set(
|
||||
(headers.get('Vary') || '')
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
vary.add('Origin');
|
||||
headers.set('Vary', Array.from(vary).join(', '));
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds comprehensive security headers to response headers.
|
||||
*
|
||||
|
||||
+47
-33
@@ -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;
|
||||
@@ -128,6 +133,25 @@ export function isDockerRequest(request, url) {
|
||||
// Re-export for standard usage
|
||||
export { isAIInferenceRequest, isGitLFSRequest, isGitRequest, isHuggingFaceAPIRequest };
|
||||
|
||||
/**
|
||||
* Computes the allowed methods for a request based on protocol detection.
|
||||
* @param {Request} request
|
||||
* @param {URL} url
|
||||
* @param {import('../config/index.js').ApplicationConfig} config
|
||||
* @returns {string[]} Allowed HTTP methods for this request shape.
|
||||
*/
|
||||
export function getAllowedMethods(request, url, config = CONFIG) {
|
||||
const isGit = isGitRequest(request, url);
|
||||
const isGitLFS = isGitLFSRequest(request, url);
|
||||
const isDocker = isDockerRequest(request, url);
|
||||
const isAI = isAIInferenceRequest(request, url);
|
||||
const isHF = isHuggingFaceAPIRequest(request, url);
|
||||
|
||||
return isGit || isGitLFS || isDocker || isAI || isHF
|
||||
? ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
: config.SECURITY.ALLOWED_METHODS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates incoming requests against security rules.
|
||||
*
|
||||
@@ -144,17 +168,7 @@ export { isAIInferenceRequest, isGitLFSRequest, isGitRequest, isHuggingFaceAPIRe
|
||||
* @returns {{valid: boolean, error?: string, status?: number}} Validation result object
|
||||
*/
|
||||
export function validateRequest(request, url, config = CONFIG) {
|
||||
// Allow POST method for Git, Git LFS, Docker, AI inference, and HF API operations
|
||||
const isGit = isGitRequest(request, url);
|
||||
const isGitLFS = isGitLFSRequest(request, url);
|
||||
const isDocker = isDockerRequest(request, url);
|
||||
const isAI = isAIInferenceRequest(request, url);
|
||||
const isHF = isHuggingFaceAPIRequest(request, url);
|
||||
|
||||
const allowedMethods =
|
||||
isGit || isGitLFS || isDocker || isAI || isHF
|
||||
? ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
: config.SECURITY.ALLOWED_METHODS;
|
||||
const allowedMethods = getAllowedMethods(request, url, config);
|
||||
|
||||
if (!allowedMethods.includes(request.method)) {
|
||||
return { valid: false, error: 'Method not allowed', status: 405 };
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('Security Features', () => {
|
||||
expect(response.status).not.toBe(500);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}, 45000);
|
||||
|
||||
it('should reject extremely long paths', async () => {
|
||||
const longPath = `/gh/${'a'.repeat(3000)}`;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Integration Tests', () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
expect([200, 301, 302, 404, 408]).toContain(response.status);
|
||||
}, 60000);
|
||||
|
||||
it('should proxy GitLab file requests correctly', async () => {
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import worker from '../../src/index.js';
|
||||
|
||||
/** @type {ExecutionContext} */
|
||||
const executionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
};
|
||||
|
||||
describe('CORS and Proxy Request Options', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('caches', {
|
||||
default: {
|
||||
match: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not send a synthetic Origin header upstream', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo/index.html'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const upstreamHeaders = new Headers(fetchSpy.mock.calls[0][1]?.headers);
|
||||
expect(upstreamHeaders.has('Origin')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not enable Cloudflare minification for proxied responses', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('<html>ok</html>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
})
|
||||
);
|
||||
|
||||
await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo/index.html'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
const fetchOptions = /** @type {RequestInit & { cf?: Record<string, unknown> }} */ (
|
||||
fetchSpy.mock.calls[0][1] || {}
|
||||
);
|
||||
|
||||
expect(fetchOptions.cf).toEqual(
|
||||
expect.objectContaining({
|
||||
http3: true,
|
||||
cacheEverything: true,
|
||||
preconnect: true
|
||||
})
|
||||
);
|
||||
expect(fetchOptions.cf).not.toHaveProperty('minify');
|
||||
});
|
||||
|
||||
it('responds to preflight requests for allowed origins', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-Custom-Header'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ALLOWED_ORIGINS: 'https://app.example.com'
|
||||
},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('GET');
|
||||
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('X-Custom-Header');
|
||||
});
|
||||
|
||||
it('rejects preflight requests for disallowed origins', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://evil.example.com',
|
||||
'Access-Control-Request-Method': 'GET'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ALLOWED_ORIGINS: 'https://app.example.com'
|
||||
},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds CORS headers to normal responses for allowed origins', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
headers: {
|
||||
Origin: 'https://app.example.com'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ALLOWED_ORIGINS: 'https://app.example.com'
|
||||
},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
|
||||
expect(response.headers.get('Vary')).toContain('Origin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Package manifest', () => {
|
||||
it('does not depend on itself', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require('../../package.json');
|
||||
const { dependencies } = packageJson;
|
||||
const typedDependencies = /** @type {Record<string, string> | undefined} */ (dependencies);
|
||||
|
||||
expect(packageJson.name).toBe('xget');
|
||||
expect(typedDependencies?.xget).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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,269 @@
|
||||
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 { 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('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);
|
||||
|
||||
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/');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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'
|
||||
});
|
||||
});
|
||||
|
||||
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