fix: harden proxy error handling and regression tests

This commit is contained in:
xixu-me committed 2026-03-14 17:33:06 +08:00
1 parent 7340cb481f
commit 7bec13b3e5
3 files changed
+184 -388

No files matched your search

+27 -28
View File
@@ -39,6 +39,7 @@ import { getAllowedMethods, isDockerRequest, validateRequest } from './utils/val
*/
async function handleRequest(request, env, ctx) {
let response;
let responseGeneratedLocally = false;
const monitor = new PerformanceMonitor();
try {
@@ -174,6 +175,9 @@ async function handleRequest(request, env, ctx) {
// Check if this is a Hugging Face API request
const isHF = isHuggingFaceAPIRequest(request, url);
const canUseCache = request.method === 'GET' || request.method === 'HEAD';
const shouldPassthroughRequest =
isGit || isGitLFS || isDocker || isAI || isHF || !canUseCache;
// Check cache first (skip cache for Git, Git LFS, Docker, AI inference, and HF API operations)
/** @type {Cache | null} */
@@ -185,6 +189,7 @@ async function handleRequest(request, env, ctx) {
if (
cache &&
canUseCache &&
!isGit &&
!isGitLFS &&
!isDocker &&
@@ -234,21 +239,16 @@ async function handleRequest(request, env, ctx) {
redirect: 'follow'
};
// Add body for POST/PUT/PATCH/DELETE requests (Git/Docker/AI/HF operations)
if (
['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) &&
(isGit || isGitLFS || isDocker || isAI || isHF)
) {
if (request.body !== null && !canUseCache) {
fetchOptions.body = request.body;
}
// Cast headers to Headers for proper typing
const requestHeaders = /** @type {Headers} */ (fetchOptions.headers);
// Set appropriate headers for Git/Docker/AI/HF vs regular requests
if (isGit || isGitLFS || isDocker || isAI || isHF) {
// For Git/Docker/AI/HF operations, copy all headers from the original request
// This ensures protocol compliance
// Preserve caller-supplied headers for protocol requests and for
// explicitly enabled non-GET/HEAD methods on regular platforms.
if (shouldPassthroughRequest) {
for (const [key, value] of request.headers.entries()) {
// Skip headers that might cause issues with proxying
if (
@@ -273,7 +273,7 @@ async function handleRequest(request, env, ctx) {
configureHuggingFaceHeaders(requestHeaders, request);
}
} else {
// Regular file download headers
// Regular GET/HEAD file download headers
Object.assign(fetchOptions, {
cf: {
http3: true,
@@ -312,11 +312,13 @@ async function handleRequest(request, env, ctx) {
// Implement retry mechanism
let attempts = 0;
while (attempts < config.MAX_RETRIES) {
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timeoutId;
try {
monitor.mark(`attempt_${attempts}`);
const controller = new AbortController();
const timeoutId = setTimeout(
timeoutId = setTimeout(
() => controller.abort(),
config.TIMEOUT_SECONDS * 1000
);
@@ -373,8 +375,6 @@ async function handleRequest(request, env, ctx) {
response = await fetch(targetUrl, finalFetchOptions);
}
clearTimeout(timeoutId);
// Handle manual redirect for Docker
if (
isDocker &&
@@ -496,30 +496,28 @@ async function handleRequest(request, env, ctx) {
attempts++;
if (error instanceof Error && error.name === 'AbortError') {
response = createErrorResponse('Request timeout', 408);
responseGeneratedLocally = true;
break;
}
if (attempts >= config.MAX_RETRIES) {
const message = error instanceof Error ? error.message : String(error);
response = createErrorResponse(
`Failed after ${config.MAX_RETRIES} attempts: ${message}`,
500,
true
);
response = createErrorResponse('Upstream request failed', 502);
responseGeneratedLocally = true;
break;
}
await new Promise(resolve =>
setTimeout(resolve, config.RETRY_DELAY_MS * attempts)
);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
if (!response) {
response = createErrorResponse(
'No response received after all retry attempts',
500,
true
);
} else if (!response.ok && response.status !== 206) {
response = createErrorResponse('No response received after all retry attempts', 500);
responseGeneratedLocally = true;
} else if (!responseGeneratedLocally && !response.ok && response.status !== 206) {
if (isDocker && response.status === 401) {
if (!response.headers.has('WWW-Authenticate')) {
// Handle Docker 401 responses that might not have been caught by the retry loop
@@ -578,7 +576,9 @@ async function handleRequest(request, env, ctx) {
}
if (!isGit && !isGitLFS && !isDocker && !isAI && !isHF) {
if (hasSensitiveHeaders) {
if (!canUseCache) {
headers.set('Cache-Control', 'no-store');
} else if (hasSensitiveHeaders) {
headers.set('Cache-Control', 'private, no-store');
const existingVary = headers.get('Vary');
headers.set(
@@ -673,8 +673,7 @@ async function handleRequest(request, env, ctx) {
}
} catch (error) {
console.error('Error handling request:', error);
const message = error instanceof Error ? error.message : String(error);
response = createErrorResponse(`Internal Server Error: ${message}`, 500, true);
response = createErrorResponse('Internal Server Error', 500);
}
// Ensure performance headers are added to the final response
+43 -360
View File
@@ -1,130 +1,13 @@
import { describe, expect, it } from 'vitest';
// Mock utility functions for testing
// These would normally be imported from actual utility modules
/**
* Check if request is a Git request
* @param {Request} request - Request object
* @param {URL} url - URL object
* @returns {boolean} True if Git request
*/
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
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;
}
/**
* Check if request is a Git LFS request
* @param {Request} request - Request object
* @param {URL} url - URL object
* @returns {boolean} True if Git LFS request
*/
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;
}
/**
* Validate request method and path
* @param {Request} request - Request object
* @param {URL} url - URL object
* @returns {{valid: boolean, error?: string, status?: number}} Validation result
*/
function validateRequest(request, url) {
const CONFIG = {
SECURITY: {
ALLOWED_METHODS: ['GET', 'HEAD'],
MAX_PATH_LENGTH: 2048
}
};
// Allow POST method for Git operations
const allowedMethods = isGitRequest(request, url)
? ['GET', 'HEAD', 'POST']
: 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 };
}
/**
* Add security headers to response headers
* @param {Headers} headers - Response headers
* @returns {Headers} Headers with security headers added
*/
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;
}
import { createConfig } from '../../src/config/index.js';
import { isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js';
import {
addSecurityHeaders,
createErrorResponse,
resolveAllowedOrigin
} from '../../src/utils/security.js';
import { getAllowedMethods, validateRequest } from '../../src/utils/validation.js';
describe('Utility Functions', () => {
describe('isGitRequest', () => {
@@ -135,20 +18,6 @@ describe('Utility Functions', () => {
expect(isGitRequest(request, url)).toBe(true);
});
it('should identify Git upload-pack requests', () => {
const request = new Request('https://example.com/repo.git/git-upload-pack');
const url = new URL(request.url);
expect(isGitRequest(request, url)).toBe(true);
});
it('should identify Git receive-pack requests', () => {
const request = new Request('https://example.com/repo.git/git-receive-pack');
const url = new URL(request.url);
expect(isGitRequest(request, url)).toBe(true);
});
it('should identify Git requests by User-Agent', () => {
const request = new Request('https://example.com/repo.git', {
headers: { 'User-Agent': 'git/2.34.1' }
@@ -158,46 +27,15 @@ describe('Utility Functions', () => {
expect(isGitRequest(request, url)).toBe(true);
});
it('should identify Git requests by service parameter', () => {
const request = new Request('https://example.com/repo.git/info/refs?service=git-upload-pack');
const url = new URL(request.url);
expect(isGitRequest(request, url)).toBe(true);
});
it('should identify Git requests by content type', () => {
const request = new Request('https://example.com/repo.git/git-upload-pack', {
method: 'POST',
headers: { 'Content-Type': 'application/x-git-upload-pack-request' }
});
const url = new URL(request.url);
expect(isGitRequest(request, url)).toBe(true);
});
it('should not identify regular file requests as Git', () => {
const request = new Request('https://example.com/repo/file.txt');
const url = new URL(request.url);
expect(isGitRequest(request, url)).toBe(false);
});
it('should handle edge cases gracefully', () => {
const request = new Request('https://example.com/');
const url = new URL(request.url);
expect(isGitRequest(request, url)).toBe(false);
});
});
describe('isGitLFSRequest', () => {
it('should identify LFS info/lfs requests', () => {
const request = new Request('https://example.com/repo.git/info/lfs');
const url = new URL(request.url);
expect(isGitLFSRequest(request, url)).toBe(true);
});
it('should identify LFS batch API requests', () => {
const request = new Request('https://example.com/repo.git/objects/batch', {
method: 'POST',
@@ -208,34 +46,6 @@ describe('Utility Functions', () => {
expect(isGitLFSRequest(request, url)).toBe(true);
});
it('should identify LFS object storage requests by path', () => {
const request = new Request(
'https://example.com/repo.git/objects/a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd'
);
const url = new URL(request.url);
expect(isGitLFSRequest(request, url)).toBe(true);
});
it('should identify LFS requests by Accept header', () => {
const request = new Request('https://example.com/repo.git/objects/batch', {
headers: { Accept: 'application/vnd.git-lfs+json' }
});
const url = new URL(request.url);
expect(isGitLFSRequest(request, url)).toBe(true);
});
it('should identify LFS requests by Content-Type header', () => {
const request = new Request('https://example.com/repo.git/objects/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/vnd.git-lfs+json' }
});
const url = new URL(request.url);
expect(isGitLFSRequest(request, url)).toBe(true);
});
it('should identify LFS requests by User-Agent', () => {
const request = new Request('https://example.com/repo.git', {
headers: { 'User-Agent': 'git-lfs/3.0.0 (GitHub; darwin amd64; go 1.17.2)' }
@@ -251,20 +61,6 @@ describe('Utility Functions', () => {
expect(isGitLFSRequest(request, url)).toBe(false);
});
it('should not identify standard Git requests as LFS', () => {
const request = new Request('https://example.com/repo.git/info/refs');
const url = new URL(request.url);
expect(isGitLFSRequest(request, url)).toBe(false);
});
it('should handle edge cases gracefully', () => {
const request = new Request('https://example.com/');
const url = new URL(request.url);
expect(isGitLFSRequest(request, url)).toBe(false);
});
});
describe('validateRequest', () => {
@@ -272,27 +68,10 @@ describe('Utility Functions', () => {
const request = new Request('https://example.com/test', { method: 'GET' });
const url = new URL(request.url);
const result = validateRequest(request, url);
const result = validateRequest(request, url, createConfig());
expect(result.valid).toBe(true);
});
it('should allow HEAD requests', () => {
const request = new Request('https://example.com/test', { method: 'HEAD' });
const url = new URL(request.url);
const result = validateRequest(request, url);
expect(result.valid).toBe(true);
});
it('should reject PUT requests for non-Git operations', () => {
const request = new Request('https://example.com/test', { method: 'PUT' });
const url = new URL(request.url);
const result = validateRequest(request, url);
expect(result.valid).toBe(false);
expect(result.status).toBe(405);
});
it('should allow POST requests for Git operations', () => {
const request = new Request('https://example.com/repo.git/git-upload-pack', {
method: 'POST',
@@ -300,27 +79,27 @@ describe('Utility Functions', () => {
});
const url = new URL(request.url);
const result = validateRequest(request, url);
const result = validateRequest(request, url, createConfig());
expect(result.valid).toBe(true);
});
it('should reject extremely long paths', () => {
const longPath = `/${'a'.repeat(3000)}`;
const request = new Request(`https://example.com${longPath}`);
it('should reject encoded traversal attempts against the production validator', () => {
const request = new Request('https://example.com/gh/user/repo/%2e%2e%2fsecret');
const url = new URL(request.url);
const result = validateRequest(request, url);
const result = validateRequest(request, url, createConfig());
expect(result.valid).toBe(false);
expect(result.status).toBe(414);
expect(result.status).toBe(400);
});
});
it('should accept normal length paths', () => {
const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip';
const request = new Request(`https://example.com${normalPath}`);
describe('getAllowedMethods', () => {
it('should respect configured methods for regular requests', () => {
const config = createConfig({ ALLOWED_METHODS: 'GET,HEAD,POST' });
const request = new Request('https://example.com/gh/test/repo/issues', { method: 'POST' });
const url = new URL(request.url);
const result = validateRequest(request, url);
expect(result.valid).toBe(true);
expect(getAllowedMethods(request, url, config)).toEqual(['GET', 'HEAD', 'POST']);
});
});
@@ -329,41 +108,12 @@ describe('Utility Functions', () => {
const headers = new Headers();
const result = addSecurityHeaders(headers);
expect(result.get('Strict-Transport-Security')).toBeTruthy();
expect(result.get('Strict-Transport-Security')).toContain('max-age=31536000');
expect(result.get('X-Frame-Options')).toBe('DENY');
expect(result.get('X-XSS-Protection')).toBe('1; mode=block');
expect(result.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
expect(result.get('Content-Security-Policy')).toBeTruthy();
expect(result.get('Permissions-Policy')).toBeTruthy();
});
it('should set HSTS with proper directives', () => {
const headers = new Headers();
const result = addSecurityHeaders(headers);
const hsts = result.get('Strict-Transport-Security');
expect(hsts).toContain('max-age=31536000');
expect(hsts).toContain('includeSubDomains');
expect(hsts).toContain('preload');
});
it('should set CSP with restrictive policy', () => {
const headers = new Headers();
const result = addSecurityHeaders(headers);
const csp = result.get('Content-Security-Policy');
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("script-src 'none'");
});
it('should not overwrite existing headers', () => {
const headers = new Headers();
headers.set('X-Custom-Header', 'custom-value');
const result = addSecurityHeaders(headers);
expect(result.get('X-Custom-Header')).toBe('custom-value');
expect(result.get('X-Frame-Options')).toBe('DENY');
expect(result.get('Content-Security-Policy')).toContain("default-src 'none'");
expect(result.get('Permissions-Policy')).toContain('interest-cohort=()');
});
it('should return the same Headers object', () => {
@@ -374,101 +124,34 @@ describe('Utility Functions', () => {
});
});
describe('URL and Path Utilities', () => {
it('should handle URL parsing correctly', () => {
const testUrls = [
'https://example.com/gh/user/repo/file.txt',
'https://example.com/gl/group/project/-/blob/main/README.md',
'https://example.com/hf/microsoft/model/resolve/main/config.json'
];
testUrls.forEach(urlString => {
expect(() => new URL(urlString)).not.toThrow();
const url = new URL(urlString);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('example.com');
expect(url.pathname).toBeTruthy();
describe('resolveAllowedOrigin', () => {
it('should return the matching origin from the production config', () => {
const config = createConfig({ ALLOWED_ORIGINS: 'https://app.example.com' });
const request = new Request('https://example.com/gh/test/repo', {
headers: { Origin: 'https://app.example.com' }
});
expect(resolveAllowedOrigin(request, config)).toBe('https://app.example.com');
});
it('should handle query parameters correctly', () => {
const url = new URL('https://example.com/gh/repo?ref=main&path=src');
it('should reject origins that are not configured', () => {
const config = createConfig({ ALLOWED_ORIGINS: 'https://app.example.com' });
const request = new Request('https://example.com/gh/test/repo', {
headers: { Origin: 'https://evil.example.com' }
});
expect(url.searchParams.get('ref')).toBe('main');
expect(url.searchParams.get('path')).toBe('src');
expect(url.searchParams.has('nonexistent')).toBe(false);
});
it('should handle URL fragments correctly', () => {
const url = new URL('https://example.com/gh/repo/README.md#section');
expect(url.hash).toBe('#section');
expect(url.pathname).toBe('/gh/repo/README.md');
expect(resolveAllowedOrigin(request, config)).toBeNull();
});
});
describe('Request and Response Utilities', () => {
it('should create requests with proper headers', () => {
const request = new Request('https://example.com/test', {
method: 'GET',
headers: {
'User-Agent': 'Xget/1.0',
Accept: 'application/json'
}
});
describe('createErrorResponse', () => {
it('should create a plain-text error response with security headers', async () => {
const response = createErrorResponse('Bad Request', 400);
expect(request.method).toBe('GET');
expect(request.headers.get('User-Agent')).toBe('Xget/1.0');
expect(request.headers.get('Accept')).toBe('application/json');
});
it('should handle request cloning', () => {
const originalRequest = new Request('https://example.com/test', {
method: 'POST',
body: 'test data',
headers: { 'Content-Type': 'text/plain' }
});
const clonedRequest = originalRequest.clone();
expect(clonedRequest.method).toBe(originalRequest.method);
expect(clonedRequest.url).toBe(originalRequest.url);
expect(clonedRequest.headers.get('Content-Type')).toBe('text/plain');
});
it('should create responses with proper status codes', () => {
const responses = [
new Response('OK', { status: 200 }),
new Response('Not Found', { status: 404 }),
new Response('Server Error', { status: 500 })
];
expect(responses[0].status).toBe(200);
expect(responses[1].status).toBe(404);
expect(responses[2].status).toBe(500);
});
});
describe('Error Handling Utilities', () => {
it('should create proper error responses', () => {
const errorResponse = new Response('Bad Request', {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'text/plain' }
});
expect(errorResponse.status).toBe(400);
expect(errorResponse.statusText).toBe('Bad Request');
expect(errorResponse.headers.get('Content-Type')).toBe('text/plain');
});
it('should handle async error scenarios', async () => {
const asyncFunction = async () => {
throw new Error('Test error');
};
await expect(asyncFunction()).rejects.toThrow('Test error');
expect(response.status).toBe(400);
expect(response.headers.get('Content-Type')).toBe('text/plain');
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(await response.text()).toBe('Bad Request');
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import worker from '../../src/index.js';
/** @type {ExecutionContext} */
const executionContext = {
waitUntil() {},
passThroughOnException() {}
};
describe('Worker regression coverage', () => {
/** @type {{ match: ReturnType<typeof vi.fn>, put: ReturnType<typeof vi.fn> }} */
let cacheDefault;
beforeEach(() => {
cacheDefault = {
match: vi.fn(async () => null),
put: vi.fn(async () => undefined)
};
vi.stubGlobal('caches', {
default: cacheDefault
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('does not leak thrown upstream error details to clients', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('secret-upstream-detail'));
const response = await worker.fetch(
new Request('https://example.com/gh/user/repo/file.txt'),
{ MAX_RETRIES: '1', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '1' },
executionContext
);
const body = await response.text();
expect(response.status).toBe(502);
expect(body).not.toContain('secret-upstream-detail');
expect(body).not.toContain('Failed after');
});
it('clears timeout handles when upstream fetch rejects', async () => {
const timeoutToken = { id: 'timeout-token' };
const setTimeoutSpy = vi.fn(() => timeoutToken);
const clearTimeoutSpy = vi.fn();
vi.stubGlobal('setTimeout', setTimeoutSpy);
vi.stubGlobal('clearTimeout', clearTimeoutSpy);
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('boom'));
const response = await worker.fetch(
new Request('https://example.com/gh/user/repo/file.txt'),
{ MAX_RETRIES: '1', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '5' },
executionContext
);
expect(response.status).toBe(502);
expect(setTimeoutSpy).toHaveBeenCalled();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutToken);
});
it('forwards body and content type for configured non-protocol POST requests', async () => {
/** @type {{ url: string, method: string | undefined, body: string | null, contentType: string | null, cf: unknown }} */
let observed = {
url: '',
method: undefined,
body: null,
contentType: null,
cf: undefined
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
observed = {
url: String(input),
method: init?.method,
body: init?.body ? await new Response(init.body).text() : null,
contentType: new Headers(init?.headers).get('Content-Type'),
cf: /** @type {RequestInit & { cf?: unknown }} */ (init || {}).cf
};
return new Response('created', {
status: 201,
headers: { 'Content-Type': 'text/plain' }
});
});
const response = await worker.fetch(
new Request('https://example.com/gh/user/repo/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'test' })
}),
{ ALLOWED_METHODS: 'GET,HEAD,POST' },
executionContext
);
expect(response.status).toBe(201);
expect(observed).toEqual({
url: 'https://github.com/user/repo/issues',
method: 'POST',
body: JSON.stringify({ title: 'test' }),
contentType: 'application/json',
cf: undefined
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(cacheDefault.match).not.toHaveBeenCalled();
expect(response.headers.get('Cache-Control')).toBe('no-store');
});
});