feat: refactor AI inference request detection and Docker authentication handling

This commit is contained in:
xixu-me committed 2026-03-09 17:49:47 +08:00
1 parent 89dc392374
commit 18247d5cfe
16 files changed
+787 -242

No files matched your search

+1 -6
View File
@@ -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
View File
@@ -1,7 +1,6 @@
{
"dependencies": {
"express": "^5.2.1",
"xget": "file:"
"express": "^5.2.1"
},
"devDependencies": {
"@commitlint/cli": "^20.1.0",
+15 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 || '');
}
+53
View File
@@ -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
View File
@@ -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 };
+1 -1
View File
@@ -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)}`;
+19
View File
@@ -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', () => {
+6 -8
View File
@@ -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', () => {
-36
View File
@@ -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
+138
View File
@@ -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');
});
});
+15
View File
@@ -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();
});
});
+4
View File
@@ -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',
+269
View File
@@ -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)
);
});
});