fix: harden proxy behavior and stabilize ci
This commit is contained in:
1 parent
213e04de76
commit
cc3abf89c3
10 files changed
+294
-36
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
|
||||
|
||||
+49
-11
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user