diff --git a/README.md b/README.md index ee2089d..3416b32 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,9 @@ standalone `/xget` directory in a skills installation - `Content-Security-Policy`: Strict content security policy - `Referrer-Policy`: Controls referrer information leakage - **Request Validation Mechanism**: - - HTTP method whitelist: Regular requests limited to GET/HEAD, Git operations - dynamically allow POST + - HTTP method whitelist: Regular requests limited to GET/HEAD, while + Git/LFS, container registry, AI inference, and Hugging Face API traffic + allow `POST`, `PUT`, `PATCH`, and `DELETE` as needed - Path length limit: Prevents excessively long URL attacks (max 2048 characters) - Input sanitization: Prevents path traversal and injection attacks @@ -2859,7 +2860,7 @@ export const CONFIG = { RETRY_DELAY_MS: 1000, // Retry delay (milliseconds) CACHE_DURATION: 1800, // Cache duration (1800 seconds = 30 minutes) SECURITY: { - ALLOWED_METHODS: ['GET', 'HEAD'], // Allowed HTTP methods (Git operations dynamically allow POST) + ALLOWED_METHODS: ['GET', 'HEAD'], // Base allowlist for regular requests; protocol traffic has broader built-in allowances ALLOWED_ORIGINS: ['*'], // Allowed CORS origins MAX_PATH_LENGTH: 2048 // Maximum path length (characters) } diff --git a/README.zh-Hans.md b/README.zh-Hans.md index cfef2d4..aff9ab8 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -101,7 +101,7 @@ Xget 已受邀入驻 - `Content-Security-Policy`:严格的内容安全策略 - `Referrer-Policy`:控制引用信息泄露 - **请求验证机制**: - - HTTP 方法白名单:常规请求限制为 GET/HEAD,Git 操作动态允许 POST + - HTTP 方法白名单:常规请求限制为 GET/HEAD,而 Git/LFS、容器镜像仓库、AI 推理和 Hugging Face API 请求会按需允许 `POST`、`PUT`、`PATCH` 和 `DELETE` - 路径长度限制:防止超长 URL 攻击(最大 2048 字符) - 输入清理:防止路径遍历和注入攻击 - **超时保护**:30 秒请求超时,防止资源耗尽和恶意请求 @@ -2777,7 +2777,7 @@ export const CONFIG = { RETRY_DELAY_MS: 1000, // 重试延迟时间(毫秒) CACHE_DURATION: 1800, // 缓存持续时间(1800秒 = 30分钟) SECURITY: { - ALLOWED_METHODS: ['GET', 'HEAD'], // 允许的 HTTP 方法(Git 操作会动态允许 POST) + ALLOWED_METHODS: ['GET', 'HEAD'], // 常规请求的基础允许列表;协议流量内置了更宽的允许范围 ALLOWED_ORIGINS: ['*'], // 允许的 CORS 源 MAX_PATH_LENGTH: 2048 // 最大路径长度(字符) } diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 8d4d431..9447463 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -101,7 +101,7 @@ Xget 已受邀入駐 - `Content-Security-Policy`:嚴格的內容安全策略 - `Referrer-Policy`:控制參照來源資訊洩露 - **請求驗證機制**: - - HTTP 方法白名單:常規請求限制為 GET/HEAD,Git 操作動態允許 POST + - HTTP 方法白名單:常規請求限制為 GET/HEAD,而 Git/LFS、容器映像倉庫、AI 推理與 Hugging Face API 請求會按需允許 `POST`、`PUT`、`PATCH` 和 `DELETE` - 路徑長度限制:防止超長 URL 攻擊(最大 2048 字元) - 輸入清理:防止路徑遍歷和注入攻擊 - **逾時保護**:30 秒請求逾時,防止資源耗盡和惡意請求 @@ -2776,7 +2776,7 @@ export const CONFIG = { RETRY_DELAY_MS: 1000, // 重試延遲時間(毫秒) CACHE_DURATION: 1800, // 快取持續時間(1800秒 = 30分鐘) SECURITY: { - ALLOWED_METHODS: ['GET', 'HEAD'], // 允許的 HTTP 方法(Git 操作會動態允許 POST) + ALLOWED_METHODS: ['GET', 'HEAD'], // 常規請求的基礎允許清單;協定流量內建了更寬的允許範圍 ALLOWED_ORIGINS: ['*'], // 允許的 CORS 來源 MAX_PATH_LENGTH: 2048 // 最大路徑長度(字元) } diff --git a/src/index.js b/src/index.js index cac5d63..ecf6266 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ import { handleDockerAuth, normalizeRegistryApiPath, parseAuthenticate, + readRegistryTokenResponse, responseUnauthorized } from './protocols/docker.js'; import { configureGitHeaders, isGitLFSRequest, isGitRequest } from './protocols/git.js'; @@ -431,10 +432,10 @@ async function handleRequest(request, env, ctx) { ); if (tokenResponse.ok) { - const tokenData = await tokenResponse.json(); - if (tokenData.token) { + const token = await readRegistryTokenResponse(tokenResponse); + if (token) { const retryHeaders = new Headers(requestHeaders); - retryHeaders.set('Authorization', `Bearer ${tokenData.token}`); + retryHeaders.set('Authorization', `Bearer ${token}`); const retryOptions = { ...finalFetchOptions, diff --git a/src/protocols/docker.js b/src/protocols/docker.js index d583760..c912a42 100644 --- a/src/protocols/docker.js +++ b/src/protocols/docker.js @@ -38,7 +38,7 @@ export function parseAuthenticate(authenticateStr) { const serviceMatch = authenticateStr.match(/service="([^"]+)"/); if (!realmMatch || !serviceMatch) { - throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`); + throw new Error(`invalid WWW-Authenticate header: ${authenticateStr}`); } return { @@ -72,6 +72,41 @@ export async function fetchToken(wwwAuthenticate, scope, authorization) { return await fetch(url, { method: 'GET', headers }); } +/** + * Reads a bearer token from an upstream registry token response. + * + * Registry token services commonly return either `token` or `access_token`. + * Some registries also respond with an empty or malformed body on transient + * failures, so this parser fails closed and lets the caller fall back to the + * standard 401 challenge flow. + * @param {Response} response + * @returns {Promise} Resolved bearer token, or null when unavailable. + */ +export async function readRegistryTokenResponse(response) { + const rawBody = await response.text().catch(() => ''); + if (!rawBody.trim()) { + return null; + } + + try { + const parsed = JSON.parse(rawBody); + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const tokenValue = + 'token' in parsed && typeof parsed.token === 'string' + ? parsed.token + : 'access_token' in parsed && typeof parsed.access_token === 'string' + ? parsed.access_token + : null; + + return tokenValue; + } catch { + return null; + } +} + /** * Parses the request URL to determine the appropriate Docker registry scope. * diff --git a/src/utils/validation.js b/src/utils/validation.js index 34f2425..1569576 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -156,12 +156,12 @@ export function getAllowedMethods(request, url, config = CONFIG) { * Validates incoming requests against security rules. * * Performs security validation including: - * - HTTP method validation (with special allowances for Git/Docker/AI operations) + * - HTTP method validation (with special allowances for protocol-specific 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 + * - Git/LFS/Docker/AI/Hugging Face API: GET, HEAD, POST, PUT, PATCH, DELETE * @param {Request} request - The incoming request object * @param {URL} url - Parsed URL object * @param {import('../config/index.js').ApplicationConfig} config - Configuration object diff --git a/test/features/auth.test.js b/test/features/auth.test.js index eab0961..09d716c 100644 --- a/test/features/auth.test.js +++ b/test/features/auth.test.js @@ -1,70 +1,134 @@ -import { SELF } from 'cloudflare:test'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import worker from '../../src/index.js'; + +/** @type {ExecutionContext} */ +const executionContext = { + waitUntil() {}, + passThroughOnException() {} +}; describe('Authentication Header Forwarding', () => { - it('should forward Authorization header for Hugging Face requests', async () => { - // Test with a Hugging Face dataset file that would require authentication - const testUrl = 'https://example.com/hf/datasets/test/private-dataset/resolve/main/data.csv'; - const authToken = 'Bearer hf_test_token_12345'; + /** @type {{ match: ReturnType, put: ReturnType }} */ + let cacheDefault; - const response = await SELF.fetch(testUrl, { - method: 'HEAD', - headers: { - Authorization: authToken - } - }); + beforeEach(() => { + cacheDefault = { + match: vi.fn(async () => null), + put: vi.fn(async () => undefined) + }; - // Should accept the request (not 400 bad request) - expect(response.status).not.toBe(400); - // Should attempt to proxy to HF with auth (status depends on actual HF response) - expect([200, 401, 403, 404]).toContain(response.status); + vi.stubGlobal('caches', { default: cacheDefault }); }); - it('should forward Authorization header for GitHub API requests', async () => { - const testUrl = 'https://example.com/gh/test/private-repo/README.md'; + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('forwards Authorization for authenticated file requests and disables caching', async () => { const authToken = 'Bearer ghp_test_token_12345'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); - const response = await SELF.fetch(testUrl, { - method: 'HEAD', - headers: { - Authorization: authToken - } - }); + const response = await worker.fetch( + new Request('https://example.com/gh/test/private-repo/README.md', { + method: 'HEAD', + headers: { + Authorization: authToken + } + }), + {}, + executionContext + ); - // Should accept the request and forward the auth header - expect(response.status).not.toBe(400); - expect([200, 401, 403, 404]).toContain(response.status); + expect(response.status).toBe(200); + expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken); + expect(cacheDefault.match).not.toHaveBeenCalled(); + expect(cacheDefault.put).not.toHaveBeenCalled(); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); }); - it('should forward Authorization header for PyPI authenticated requests', async () => { - const testUrl = 'https://example.com/pypi/simple/private-package/'; + it('forwards Authorization for Hugging Face API passthrough requests', async () => { + const authToken = 'Bearer hf_test_token_12345'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/hf/api/models/test-private-model', { + method: 'GET', + headers: { + Authorization: authToken + } + }), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken); + expect(cacheDefault.match).not.toHaveBeenCalled(); + expect(cacheDefault.put).not.toHaveBeenCalled(); + }); + + it('forwards Authorization for authenticated PyPI index requests', async () => { const authToken = 'Basic dGVzdDp0ZXN0MTIzNDU='; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }) + ); - const response = await SELF.fetch(testUrl, { - method: 'HEAD', - headers: { - Authorization: authToken - } - }); + const response = await worker.fetch( + new Request('https://example.com/pypi/simple/private-package/', { + method: 'HEAD', + headers: { + Authorization: authToken + } + }), + {}, + executionContext + ); - // Should accept the request - expect(response.status).not.toBe(400); - expect([200, 401, 403, 404]).toContain(response.status); + expect(response.status).toBe(200); + expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken); + expect(cacheDefault.match).not.toHaveBeenCalled(); + expect(cacheDefault.put).not.toHaveBeenCalled(); + expect(response.headers.get('Cache-Control')).toBe('no-store'); }); - it('should work with gated Hugging Face models', async () => { - // Simulate a request to a gated model that requires authentication - const testUrl = 'https://example.com/hf/meta-llama/Llama-2-7b/resolve/main/config.json'; + it('forwards Authorization for gated Hugging Face model downloads', async () => { const authToken = 'Bearer hf_authenticated_token'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); - const response = await SELF.fetch(testUrl, { - headers: { - Authorization: authToken - } - }); + const response = await worker.fetch( + new Request('https://example.com/hf/meta-llama/Llama-2-7b/resolve/main/config.json', { + headers: { + Authorization: authToken + } + }), + {}, + executionContext + ); - // Should attempt to proxy with authentication - // The actual status depends on whether the token is valid and the model exists - expect(response.status).not.toBe(400); + expect(response.status).toBe(200); + expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken); + expect(cacheDefault.match).not.toHaveBeenCalled(); + expect(cacheDefault.put).not.toHaveBeenCalled(); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); }); }); diff --git a/test/unit/protocols.test.js b/test/unit/protocols.test.js index 3e4e92b..72004df 100644 --- a/test/unit/protocols.test.js +++ b/test/unit/protocols.test.js @@ -2,7 +2,11 @@ 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 { + getScopeFromUrl, + handleDockerAuth, + readRegistryTokenResponse +} from '../../src/protocols/docker.js'; import { isDockerRequest } from '../../src/utils/validation.js'; /** @type {ExecutionContext} */ @@ -254,6 +258,49 @@ describe('Docker Authentication', () => { expect(response.status).toBe(200); expect(String(fetchSpy.mock.calls[1][0])).toContain('scope=repository%3Aprivate%2Frepo%3Apull'); }); + + it('treats empty JSON token responses as unusable instead of throwing', async () => { + const token = await readRegistryTokenResponse( + new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + expect(token).toBeNull(); + }); + + it('falls back cleanly when the token service returns an empty success body', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + 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('', { status: 200 }); + }); + + 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'); + expect(warnSpy).not.toHaveBeenCalled(); + }); }); describe('Protocol Header Configuration', () => {