fix: harden proxy behavior and stabilize ci

This commit is contained in:
xixu-me committed 2026-03-06 21:10:59 +08:00
1 parent 213e04de76
commit cc3abf89c3
10 files changed
+294 -36

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
+49 -11
View File
@@ -22,8 +22,8 @@ import {
} 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.
@@ -41,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'
@@ -239,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
}
});
@@ -251,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);
@@ -648,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 {
+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.
*
+20 -11
View File
@@ -133,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.
*
@@ -149,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)}`;
+1 -1
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 () => {
+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();
});
});