Refactor protocol and utility logic into separate modules
Moved Docker/OCI protocol handling, performance monitoring, security, and validation logic into dedicated modules under src/protocols and src/utils. Updated src/index.js to use these new modules, improving maintainability and separation of concerns. Added pre-computed sorted platform keys for efficient matching in platform config.
This commit is contained in:
1 parent
24a53398fb
commit
75d8594532
7 files changed
+567
-1015
No files matched your search
@@ -229,6 +229,16 @@ export const PLATFORMS = {
|
||||
'cr-gitpod': 'https://registry.gitpod.io'
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-computed sorted platforms keys for efficient matching.
|
||||
* Sorted by key length (descending) to prioritize more specific paths.
|
||||
*/
|
||||
export const SORTED_PLATFORMS = Object.keys(PLATFORMS).sort((a, b) => {
|
||||
const pathA = `/${a.replace('-', '/')}/`;
|
||||
const pathB = `/${b.replace('-', '/')}/`;
|
||||
return pathB.length - pathA.length;
|
||||
});
|
||||
|
||||
/**
|
||||
* Unified path transformation function that converts request paths to platform-specific URLs.
|
||||
*
|
||||
|
||||
+40
-1014
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Docker/OCI Registry protocol handler for Xget
|
||||
*/
|
||||
|
||||
import { SORTED_PLATFORMS } from '../config/platforms.js';
|
||||
import { createErrorResponse } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* Parses Docker/OCI registry WWW-Authenticate header.
|
||||
*
|
||||
* Extracts authentication realm and service information from the Bearer
|
||||
* authentication challenge header returned by container registries.
|
||||
*
|
||||
* @param {string} authenticateStr - The WWW-Authenticate header value
|
||||
* @returns {{realm: string, service: string}} Parsed authentication info with realm URL and service name
|
||||
* @throws {Error} If the header format is invalid or missing required fields
|
||||
*/
|
||||
export function parseAuthenticate(authenticateStr) {
|
||||
// sample: Bearer realm="https://auth.ipv6.docker.com/token",service="registry.docker.io"
|
||||
const realmMatch = authenticateStr.match(/realm="([^"]+)"/);
|
||||
const serviceMatch = authenticateStr.match(/service="([^"]+)"/);
|
||||
|
||||
if (!realmMatch || !serviceMatch) {
|
||||
throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`);
|
||||
}
|
||||
|
||||
return {
|
||||
realm: realmMatch[1],
|
||||
service: serviceMatch[1]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches authentication token from container registry token service.
|
||||
*
|
||||
* Requests a Bearer token from the registry's authentication service,
|
||||
* optionally including scope (repository permissions) and authorization credentials.
|
||||
*
|
||||
* @param {{realm: string, service: string}} wwwAuthenticate - Authentication info from WWW-Authenticate header
|
||||
* @param {string} scope - The scope for the token (e.g., "repository:library/nginx:pull")
|
||||
* @param {string} authorization - Authorization header value (optional, for authenticated access)
|
||||
* @returns {Promise<Response>} Token response containing JWT token
|
||||
*/
|
||||
export async function fetchToken(wwwAuthenticate, scope, authorization) {
|
||||
const url = new URL(wwwAuthenticate.realm);
|
||||
if (wwwAuthenticate.service.length) {
|
||||
url.searchParams.set('service', wwwAuthenticate.service);
|
||||
}
|
||||
if (scope) {
|
||||
url.searchParams.set('scope', scope);
|
||||
}
|
||||
const headers = new Headers();
|
||||
if (authorization) {
|
||||
headers.set('Authorization', authorization);
|
||||
}
|
||||
return await fetch(url, { method: 'GET', headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unauthorized (401) response for container registry authentication.
|
||||
*
|
||||
* 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
|
||||
* @returns {Response} Unauthorized response with WWW-Authenticate header
|
||||
*/
|
||||
export function responseUnauthorized(url) {
|
||||
const headers = new Headers();
|
||||
headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`);
|
||||
return new Response(JSON.stringify({ message: 'UNAUTHORIZED' }), {
|
||||
status: 401,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the special /v2/auth endpoint for Docker authentication.
|
||||
*
|
||||
* Proxies generation of auth tokens by negotiating with the upstream registry.
|
||||
*
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {URL} url - The parsed URL
|
||||
* @param {import('../config/index.js').ApplicationConfig} config - App configuration
|
||||
* @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);
|
||||
}
|
||||
|
||||
// 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 authorization = request.headers.get('Authorization');
|
||||
|
||||
// 1. Fetch the upstream root (v2) to get the proper realm and service
|
||||
// We use the upstream URL + /v2/
|
||||
const v2Url = new URL(`${upstreamUrl}/v2/`);
|
||||
const v2Resp = await fetch(v2Url.toString(), {
|
||||
method: 'GET',
|
||||
redirect: 'follow'
|
||||
});
|
||||
|
||||
if (v2Resp.status !== 401) {
|
||||
// If not 401, maybe no auth needed? Or error.
|
||||
// Just forward the response?
|
||||
return v2Resp;
|
||||
}
|
||||
|
||||
const authenticateStr = v2Resp.headers.get('WWW-Authenticate');
|
||||
if (authenticateStr === null) {
|
||||
return v2Resp;
|
||||
}
|
||||
|
||||
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 || '');
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Performance monitoring utilities for Xget
|
||||
*/
|
||||
|
||||
import { addSecurityHeaders } from './security.js';
|
||||
|
||||
/**
|
||||
* Monitors performance metrics during request processing.
|
||||
*
|
||||
* This class tracks timing information throughout request handling lifecycle,
|
||||
* allowing measurement of cache hits, upstream fetch attempts, and total processing time.
|
||||
*/
|
||||
export class PerformanceMonitor {
|
||||
/**
|
||||
* Initializes a new performance monitor.
|
||||
*
|
||||
* Sets the start time to the current timestamp and creates an empty marks collection.
|
||||
* All subsequent timing marks will be relative to this start time.
|
||||
*/
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.marks = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a timing point with the given name.
|
||||
*
|
||||
* Records the elapsed time (in milliseconds) since the monitor was created.
|
||||
* If a mark with the same name already exists, logs a warning and overwrites it.
|
||||
*
|
||||
* @param {string} name - The name of the timing mark (e.g., 'cache_hit', 'attempt_0', 'success')
|
||||
*/
|
||||
mark(name) {
|
||||
if (this.marks.has(name)) {
|
||||
console.warn(`Mark with name ${name} already exists.`);
|
||||
}
|
||||
this.marks.set(name, Date.now() - this.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all collected metrics as a plain object.
|
||||
*
|
||||
* Converts the internal Map of timing marks to a JavaScript object suitable for
|
||||
* JSON serialization and inclusion in response headers.
|
||||
*
|
||||
* @returns {Object.<string, number>} Object containing name-timestamp pairs in milliseconds
|
||||
*/
|
||||
getMetrics() {
|
||||
return Object.fromEntries(this.marks.entries());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds performance metrics to response headers.
|
||||
*
|
||||
* Creates a new response with an X-Performance-Metrics header containing
|
||||
* timing data from the PerformanceMonitor instance. Also ensures security
|
||||
* headers are included.
|
||||
*
|
||||
* **Note:** This header is only added to non-protocol responses (not Git/Docker/AI).
|
||||
*
|
||||
* @param {Response} response - The original response object
|
||||
* @param {PerformanceMonitor} monitor - Performance monitor instance with collected metrics
|
||||
* @returns {Response} New response with added performance and security headers
|
||||
*/
|
||||
export function addPerformanceHeaders(response, monitor) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('X-Performance-Metrics', JSON.stringify(monitor.getMetrics()));
|
||||
addSecurityHeaders(headers);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Security utility functions for Xget
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds comprehensive security headers to response headers.
|
||||
*
|
||||
* applies industry-standard security headers including:
|
||||
* - HSTS (HTTP Strict Transport Security)
|
||||
* - X-Frame-Options (clickjacking protection)
|
||||
* - X-XSS-Protection (XSS filter)
|
||||
* - Referrer-Policy (referrer information control)
|
||||
* - Content-Security-Policy (resource loading restrictions)
|
||||
* - Permissions-Policy (privacy-invasive feature restrictions)
|
||||
*
|
||||
* @param {Headers} headers - Headers object to modify (mutates in place)
|
||||
* @returns {Headers} Modified headers object (same reference)
|
||||
*/
|
||||
export function addSecurityHeaders(headers) {
|
||||
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('X-XSS-Protection', '1; mode=block');
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
headers.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; script-src 'none'");
|
||||
headers.set('Permissions-Policy', 'interest-cohort=()');
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error response with security headers.
|
||||
*
|
||||
* Generates an HTTP error response with appropriate content type and security headers.
|
||||
* Can return either plain text or detailed JSON error format.
|
||||
*
|
||||
* @param {string} message - Error message to display
|
||||
* @param {number} status - HTTP status code (e.g., 400, 404, 500)
|
||||
* @param {boolean} includeDetails - Whether to include detailed JSON error information
|
||||
* @returns {Response} Error response with security headers
|
||||
*/
|
||||
export function createErrorResponse(message, status, includeDetails = false) {
|
||||
const errorBody = includeDetails
|
||||
? JSON.stringify({ error: message, status, timestamp: new Date().toISOString() })
|
||||
: message;
|
||||
|
||||
return new Response(errorBody, {
|
||||
status,
|
||||
headers: addSecurityHeaders(
|
||||
new Headers({
|
||||
'Content-Type': includeDetails ? 'application/json' : 'text/plain'
|
||||
})
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Request validation utilities for Xget
|
||||
*/
|
||||
|
||||
import { CONFIG } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Detects if a request is a container registry operation (Docker/OCI).
|
||||
*
|
||||
* Identifies Docker and OCI registry requests by checking for:
|
||||
* - Registry API endpoints (/v2/...)
|
||||
* - Docker-specific User-Agent headers
|
||||
* - Docker/OCI manifest Accept headers
|
||||
*
|
||||
* @param {Request} request - The incoming request object
|
||||
* @param {URL} url - Parsed URL object
|
||||
* @returns {boolean} True if this is a container registry operation
|
||||
*/
|
||||
export function isDockerRequest(request, url) {
|
||||
// Check for container registry API endpoints
|
||||
if (url.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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a request is a Git protocol operation.
|
||||
*
|
||||
* Identifies Git requests by checking for:
|
||||
* - Git-specific endpoints (/info/refs, /git-upload-pack, /git-receive-pack)
|
||||
* - Git User-Agent headers
|
||||
* - Git service query parameters
|
||||
* - Git-specific Content-Type headers
|
||||
*
|
||||
* @param {Request} request - The incoming request object
|
||||
* @param {URL} url - Parsed URL object
|
||||
* @returns {boolean} True if this is a Git operation
|
||||
*/
|
||||
export function isGitRequest(request, url) {
|
||||
// Check for Git-specific endpoints
|
||||
if (url.pathname.endsWith('/info/refs')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Git user agents (more comprehensive check)
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
if (userAgent.includes('git/') || userAgent.startsWith('git/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Git-specific query parameters
|
||||
if (url.searchParams.has('service')) {
|
||||
const service = url.searchParams.get('service');
|
||||
return service === 'git-upload-pack' || service === 'git-receive-pack';
|
||||
}
|
||||
|
||||
// Check for Git-specific content types
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a request is a Git LFS (Large File Storage) operation.
|
||||
*
|
||||
* Identifies Git LFS requests by checking for:
|
||||
* - LFS-specific endpoints (/info/lfs, /objects/batch)
|
||||
* - LFS object storage paths (SHA-256 hash patterns)
|
||||
* - Git LFS Accept/Content-Type headers
|
||||
* - Git LFS User-Agent
|
||||
*
|
||||
* @param {Request} request - The incoming request object
|
||||
* @param {URL} url - Parsed URL object
|
||||
* @returns {boolean} True if this is a Git LFS operation
|
||||
*/
|
||||
export function isGitLFSRequest(request, url) {
|
||||
// Check for LFS-specific endpoints
|
||||
if (url.pathname.includes('/info/lfs')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/objects/batch')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for LFS object storage endpoints (SHA-256 hash is 64 hex characters)
|
||||
if (url.pathname.match(/\/objects\/[a-fA-F0-9]{64}$/)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for LFS-specific headers
|
||||
const accept = request.headers.get('Accept') || '';
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
|
||||
if (
|
||||
accept.includes('application/vnd.git-lfs') ||
|
||||
contentType.includes('application/vnd.git-lfs')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for LFS user agent
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
if (userAgent.includes('git-lfs')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a request is for an AI inference provider API.
|
||||
*
|
||||
* Identifies AI inference requests by checking for:
|
||||
* - AI provider path prefix (/ip/{provider}/...)
|
||||
* - Common AI API endpoints (chat, completions, embeddings, etc.)
|
||||
* - AI-specific URL patterns with JSON POST requests
|
||||
*
|
||||
* @param {Request} request - The incoming request object
|
||||
* @param {URL} url - Parsed URL object
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates incoming requests against security rules.
|
||||
*
|
||||
* Performs security validation including:
|
||||
* - HTTP method validation (with special allowances for Git/Docker/AI operations)
|
||||
* - URL path length limits
|
||||
*
|
||||
* Different protocols have different allowed methods:
|
||||
* - Regular requests: GET, HEAD (configurable via SECURITY.ALLOWED_METHODS)
|
||||
* - Git/LFS/Docker/AI: GET, HEAD, POST, PUT, PATCH
|
||||
*
|
||||
* @param {Request} request - The incoming request object
|
||||
* @param {URL} url - Parsed URL object
|
||||
* @param {import('../config/index.js').ApplicationConfig} config - Configuration object
|
||||
* @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, and AI inference operations
|
||||
const isGit = isGitRequest(request, url);
|
||||
const isGitLFS = isGitLFSRequest(request, url);
|
||||
const isDocker = isDockerRequest(request, url);
|
||||
const isAI = isAIInferenceRequest(request, url);
|
||||
|
||||
const allowedMethods =
|
||||
isGit || isGitLFS || isDocker || isAI
|
||||
? ['GET', 'HEAD', 'POST', 'PUT', 'PATCH']
|
||||
: config.SECURITY.ALLOWED_METHODS;
|
||||
|
||||
if (!allowedMethods.includes(request.method)) {
|
||||
return { valid: false, error: 'Method not allowed', status: 405 };
|
||||
}
|
||||
|
||||
if (url.pathname.length > config.SECURITY.MAX_PATH_LENGTH) {
|
||||
return { valid: false, error: 'Path too long', status: 414 };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -231,7 +231,7 @@ describe('Security Features', () => {
|
||||
if (response.status >= 400) {
|
||||
const body = await response.text();
|
||||
// Should not expose internal paths, stack traces, or sensitive info
|
||||
expect(body).not.toMatch(/\/[a-zA-Z]:[\\/ /); // Windows paths
|
||||
expect(body).not.toMatch(/\/[a-zA-Z]:[\\/]/); // Windows paths
|
||||
expect(body).not.toMatch(/\/home\/[^/]+/); // Unix home paths
|
||||
expect(body).not.toMatch(/at [a-zA-Z]+\.[a-zA-Z]+/); // Stack traces
|
||||
expect(body).not.toMatch(/Error: .+ at/); // Detailed error messages
|
||||
@@ -244,6 +244,11 @@ describe('Security Features', () => {
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
// Should return error or redirect
|
||||
expect([400, 404, 302, 301, 405, 501]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORS Security', () => {
|
||||
it('should handle CORS preflight requests securely', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
|
||||
Reference in new issue
Block a user