322 lines
12 KiB
JavaScript
322 lines
12 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { createConfig } from '../../src/config/index.js';
|
|
import { isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js';
|
|
import {
|
|
addCorsHeaders,
|
|
addSecurityHeaders,
|
|
createErrorResponse,
|
|
resolveAllowedOrigin
|
|
} from '../../src/utils/security.js';
|
|
import { getAllowedMethods, isDockerRequest, validateRequest } from '../../src/utils/validation.js';
|
|
|
|
describe('Utility Functions', () => {
|
|
describe('createConfig', () => {
|
|
it.each([
|
|
['-1', 300],
|
|
['0', 300],
|
|
['abc', 300],
|
|
['60', 60]
|
|
])('should parse CACHE_DURATION=%s as %i', (value, expected) => {
|
|
expect(createConfig({ CACHE_DURATION: value }).CACHE_DURATION).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('isGitRequest', () => {
|
|
it('should identify Git info/refs requests', () => {
|
|
const request = new Request('https://example.com/repo.git/info/refs');
|
|
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' }
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('isGitLFSRequest', () => {
|
|
it('should identify LFS batch API requests', () => {
|
|
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)' }
|
|
});
|
|
const url = new URL(request.url);
|
|
|
|
expect(isGitLFSRequest(request, url)).toBe(true);
|
|
});
|
|
|
|
it('should not identify regular file requests as LFS', () => {
|
|
const request = new Request('https://example.com/repo/file.txt');
|
|
const url = new URL(request.url);
|
|
|
|
expect(isGitLFSRequest(request, url)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateRequest', () => {
|
|
it('should allow GET requests', () => {
|
|
const request = new Request('https://example.com/test', { method: 'GET' });
|
|
const url = new URL(request.url);
|
|
|
|
const result = validateRequest(request, url, createConfig());
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should allow POST requests for Git operations', () => {
|
|
const request = new Request('https://example.com/repo.git/git-upload-pack', {
|
|
method: 'POST',
|
|
headers: { 'User-Agent': 'git/2.34.1' }
|
|
});
|
|
const url = new URL(request.url);
|
|
|
|
const result = validateRequest(request, url, createConfig());
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
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, createConfig());
|
|
expect(result.valid).toBe(false);
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('should reject raw traversal sequences from the original request URL', () => {
|
|
const request = /** @type {Request} */ ({
|
|
headers: new Headers(),
|
|
method: 'GET',
|
|
url: 'https://example.com/gh/user/repo/../secret'
|
|
});
|
|
const url = new URL('https://example.com/gh/user/secret');
|
|
|
|
const result = validateRequest(request, url, createConfig());
|
|
expect(result.valid).toBe(false);
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('should reject paths containing ASCII control characters', () => {
|
|
const baseUrl = new URL('https://example.com/gh/user/repo/%00file');
|
|
const request = /** @type {Request} */ ({
|
|
headers: new Headers(),
|
|
method: 'GET',
|
|
url: 'https://example.com/gh/user/repo/%00file'
|
|
});
|
|
const url = /** @type {URL} */ ({
|
|
origin: 'https://example.com',
|
|
pathname: '/gh/user/repo/\u0000file',
|
|
searchParams: baseUrl.searchParams
|
|
});
|
|
|
|
const result = validateRequest(request, url, createConfig());
|
|
expect(result.valid).toBe(false);
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('should reject malformed percent-encoded paths', () => {
|
|
const baseUrl = new URL('https://example.com/gh/user/repo/%E0%A4%A');
|
|
const request = /** @type {Request} */ ({
|
|
headers: new Headers(),
|
|
method: 'GET',
|
|
url: 'https://example.com/gh/user/repo/%E0%A4%A'
|
|
});
|
|
const url = /** @type {URL} */ ({
|
|
origin: 'https://example.com',
|
|
pathname: '/gh/user/repo/%E0%A4%A',
|
|
searchParams: baseUrl.searchParams
|
|
});
|
|
|
|
const result = validateRequest(request, url, createConfig());
|
|
expect(result.valid).toBe(false);
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('should reject unsupported methods for regular requests', () => {
|
|
const request = new Request('https://example.com/gh/user/repo/file.txt', { method: 'PATCH' });
|
|
const url = new URL(request.url);
|
|
|
|
const result = validateRequest(request, url, createConfig());
|
|
expect(result.valid).toBe(false);
|
|
expect(result.status).toBe(405);
|
|
});
|
|
|
|
it('should reject paths longer than the configured maximum', () => {
|
|
const request = new Request(`https://example.com/gh/${'a'.repeat(200)}`);
|
|
const url = new URL(request.url);
|
|
|
|
const result = validateRequest(request, url, createConfig({ MAX_PATH_LENGTH: '32' }));
|
|
expect(result.valid).toBe(false);
|
|
expect(result.status).toBe(414);
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
expect(getAllowedMethods(request, url, config)).toEqual(['GET', 'HEAD', 'POST']);
|
|
});
|
|
|
|
it('should allow mutating methods for Hugging Face API endpoints', () => {
|
|
const request = new Request('https://example.com/hf/token', { method: 'DELETE' });
|
|
const url = new URL(request.url);
|
|
|
|
expect(getAllowedMethods(request, url)).toEqual([
|
|
'GET',
|
|
'HEAD',
|
|
'POST',
|
|
'PUT',
|
|
'PATCH',
|
|
'DELETE'
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('isDockerRequest', () => {
|
|
it('should identify canonical registry API paths', () => {
|
|
const request = new Request('https://example.com/cr/ghcr/v2/demo/manifests/latest');
|
|
const url = new URL(request.url);
|
|
|
|
expect(isDockerRequest(request, url)).toBe(true);
|
|
});
|
|
|
|
it('should identify Docker requests by user agent or manifest headers', () => {
|
|
const userAgentRequest = new Request('https://example.com/cr/docker/library/nginx', {
|
|
headers: { 'User-Agent': 'docker/27.0.0' }
|
|
});
|
|
const acceptRequest = new Request('https://example.com/cr/docker/library/nginx', {
|
|
headers: { Accept: 'application/vnd.oci.image.manifest.v1+json' }
|
|
});
|
|
const contentTypeRequest = new Request('https://example.com/cr/docker/library/nginx', {
|
|
headers: { 'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json' }
|
|
});
|
|
|
|
expect(isDockerRequest(userAgentRequest, new URL(userAgentRequest.url))).toBe(true);
|
|
expect(isDockerRequest(acceptRequest, new URL(acceptRequest.url))).toBe(true);
|
|
expect(isDockerRequest(contentTypeRequest, new URL(contentTypeRequest.url))).toBe(true);
|
|
});
|
|
|
|
it('should not treat generic /cr/ requests as Docker traffic without registry hints', () => {
|
|
const request = new Request('https://example.com/cr/docker/library/nginx/readme');
|
|
const url = new URL(request.url);
|
|
|
|
expect(isDockerRequest(request, url)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('addSecurityHeaders', () => {
|
|
it('should add all required security headers', () => {
|
|
const headers = new Headers();
|
|
const result = addSecurityHeaders(headers);
|
|
|
|
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')).toContain("default-src 'none'");
|
|
expect(result.get('Permissions-Policy')).toContain('interest-cohort=()');
|
|
});
|
|
|
|
it('should return the same Headers object', () => {
|
|
const headers = new Headers();
|
|
const result = addSecurityHeaders(headers);
|
|
|
|
expect(result).toBe(headers);
|
|
});
|
|
});
|
|
|
|
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 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(resolveAllowedOrigin(request, config)).toBeNull();
|
|
});
|
|
|
|
it('should allow any origin when wildcard CORS is configured', () => {
|
|
const config = createConfig({ ALLOWED_ORIGINS: '*' });
|
|
const request = new Request('https://example.com/gh/test/repo', {
|
|
headers: { Origin: 'https://app.example.com' }
|
|
});
|
|
|
|
expect(resolveAllowedOrigin(request, config)).toBe('*');
|
|
});
|
|
});
|
|
|
|
describe('addCorsHeaders', () => {
|
|
it('should append allow headers and preserve existing Vary values', () => {
|
|
const config = createConfig({ ALLOWED_ORIGINS: '*' });
|
|
const request = new Request('https://example.com/gh/test/repo', {
|
|
headers: {
|
|
Origin: 'https://app.example.com',
|
|
'Access-Control-Request-Headers': 'X-Test-Header'
|
|
}
|
|
});
|
|
|
|
const headers = addCorsHeaders(new Headers({ Vary: 'Accept-Encoding' }), request, config);
|
|
|
|
expect(headers.get('Access-Control-Allow-Origin')).toBe('*');
|
|
expect(headers.get('Access-Control-Allow-Headers')).toBe('X-Test-Header');
|
|
expect(headers.get('Vary')).toBe('Accept-Encoding, Origin');
|
|
});
|
|
});
|
|
|
|
describe('createErrorResponse', () => {
|
|
it('should create a plain-text error response with security headers', async () => {
|
|
const response = createErrorResponse('Bad Request', 400);
|
|
|
|
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');
|
|
});
|
|
|
|
it('should create detailed JSON error responses when requested', async () => {
|
|
const response = createErrorResponse('Unauthorized', 401, true);
|
|
const body = await response.json();
|
|
|
|
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
expect(body).toMatchObject({
|
|
error: 'Unauthorized',
|
|
status: 401
|
|
});
|
|
expect(body.timestamp).toBeTruthy();
|
|
});
|
|
});
|
|
});
|