Coves frontend - a photon fork
at main 891 lines 27 kB view raw
1import { describe, it, expect, vi, beforeEach } from 'vitest' 2import type { SealedToken, InstanceURL } from '$lib/server/session' 3import { validateProxyPath } from './validate' 4 5// Mock SvelteKit types for testing - mirrors App.AuthState 6type MockAuthState = 7 | { authenticated: false } 8 | { 9 authenticated: true 10 authToken: SealedToken 11 account: { instance: InstanceURL } 12 } 13 14interface MockLocals { 15 auth: MockAuthState 16} 17 18interface MockParams { 19 path: string 20} 21 22// Mock fetch type for testing 23type MockFetch = ( 24 input: RequestInfo | URL, 25 init?: RequestInit, 26) => Promise<Response> 27 28// Create the handler function we'll test. 29// This is duplicated from the source because the real handler function is not 30// exported (it's an internal implementation detail wrapped by the exported 31// RequestHandler functions), and it uses App.Locals which requires the full 32// SvelteKit type context. The tests verify the same logic in isolation. 33async function createHandler(options: { 34 params: MockParams 35 request: Request 36 locals: MockLocals 37 fetch: MockFetch 38}): Promise<Response> { 39 const { params, request, locals, fetch: fetchFn } = options 40 const path = params.path 41 42 // Validate path for security issues 43 const pathError = validateProxyPath(path) 44 if (pathError) { 45 return new Response( 46 JSON.stringify({ error: 'Bad Request', message: pathError }), 47 { 48 status: 400, 49 headers: { 'Content-Type': 'application/json' }, 50 }, 51 ) 52 } 53 54 // Determine target instance (from session or default) 55 const instance = locals.auth.authenticated 56 ? locals.auth.account.instance 57 : 'coves.social' 58 const targetUrl = `https://${instance}/${path}` 59 60 // Build headers, injecting auth if available 61 const headers = new Headers(request.headers) 62 headers.delete('host') // Don't forward host header 63 headers.delete('connection') // Don't forward connection header 64 65 if (locals.auth.authenticated) { 66 headers.set('Authorization', `Bearer ${locals.auth.authToken}`) 67 } 68 69 try { 70 // Forward request 71 const fetchOptions: RequestInit = { 72 method: request.method, 73 headers, 74 } 75 76 // Only include body for methods that support it. 77 // We consume the body as a Blob rather than streaming request.body 78 // (ReadableStream) because Node.js undici has issues with ReadableStream 79 // bodies in fetch(), causing "expected non-null body source" errors. 80 if (request.method !== 'GET' && request.method !== 'HEAD') { 81 fetchOptions.body = await request.blob() 82 } 83 84 const response = await fetchFn(targetUrl, fetchOptions) 85 86 // Return response (strip some headers) 87 const responseHeaders = new Headers(response.headers) 88 responseHeaders.delete('content-encoding') // Let SvelteKit handle 89 90 return new Response(response.body, { 91 status: response.status, 92 headers: responseHeaders, 93 }) 94 } catch (error) { 95 // Connection error to upstream 96 console.error('Proxy error:', error) 97 return new Response( 98 JSON.stringify({ 99 error: 'Bad Gateway', 100 message: 'Failed to connect to upstream server', 101 }), 102 { 103 status: 502, 104 headers: { 'Content-Type': 'application/json' }, 105 }, 106 ) 107 } 108} 109 110/** 111 * Helper to create authenticated MockLocals 112 */ 113function createAuthenticatedLocals( 114 token: string, 115 instance: string, 116): MockLocals { 117 return { 118 auth: { 119 authenticated: true, 120 authToken: token as SealedToken, 121 account: { instance: instance as InstanceURL }, 122 }, 123 } 124} 125 126/** 127 * Helper to create unauthenticated MockLocals 128 */ 129function createUnauthenticatedLocals(): MockLocals { 130 return { auth: { authenticated: false } } 131} 132 133describe('API Proxy', () => { 134 let mockFetch: ReturnType<typeof vi.fn<MockFetch>> 135 136 beforeEach(() => { 137 mockFetch = vi.fn<MockFetch>() 138 }) 139 140 /** 141 * Helper to get the last call to mockFetch with proper typing 142 */ 143 function getLastFetchCall(): [string, RequestInit & { headers: Headers }] { 144 const calls = mockFetch.mock.calls 145 expect(calls.length).toBeGreaterThan(0) 146 const lastCall = calls[calls.length - 1]! 147 return [ 148 lastCall[0] as string, 149 lastCall[1] as RequestInit & { headers: Headers }, 150 ] 151 } 152 153 describe('authenticated requests', () => { 154 it('forwards GET request with Authorization header', async () => { 155 const mockResponse = new Response(JSON.stringify({ data: 'test' }), { 156 status: 200, 157 headers: { 'Content-Type': 'application/json' }, 158 }) 159 mockFetch.mockResolvedValue(mockResponse) 160 161 const request = new Request('http://localhost/api/proxy/api/v1/feed', { 162 method: 'GET', 163 headers: { 'Content-Type': 'application/json' }, 164 }) 165 166 const response = await createHandler({ 167 params: { path: 'api/v1/feed' }, 168 request, 169 locals: createAuthenticatedLocals( 170 'test-jwt-token', 171 'test.coves.social', 172 ), 173 fetch: mockFetch, 174 }) 175 176 expect(mockFetch).toHaveBeenCalledTimes(1) 177 const [url, options] = getLastFetchCall() 178 expect(url).toBe('https://test.coves.social/api/v1/feed') 179 expect(options.method).toBe('GET') 180 expect(options.headers.get('Authorization')).toBe('Bearer test-jwt-token') 181 expect(response.status).toBe(200) 182 }) 183 184 it('forwards POST request with body and Authorization header', async () => { 185 const mockResponse = new Response(JSON.stringify({ created: true }), { 186 status: 201, 187 headers: { 'Content-Type': 'application/json' }, 188 }) 189 mockFetch.mockResolvedValue(mockResponse) 190 191 const postBody = JSON.stringify({ title: 'Test Post', content: 'Hello' }) 192 const request = new Request('http://localhost/api/proxy/api/v1/posts', { 193 method: 'POST', 194 headers: { 'Content-Type': 'application/json' }, 195 body: postBody, 196 }) 197 198 const response = await createHandler({ 199 params: { path: 'api/v1/posts' }, 200 request, 201 locals: createAuthenticatedLocals( 202 'test-jwt-token', 203 'test.coves.social', 204 ), 205 fetch: mockFetch, 206 }) 207 208 expect(mockFetch).toHaveBeenCalledTimes(1) 209 const [url, options] = getLastFetchCall() 210 expect(url).toBe('https://test.coves.social/api/v1/posts') 211 expect(options.method).toBe('POST') 212 expect(options.headers.get('Authorization')).toBe('Bearer test-jwt-token') 213 expect(options.body).toBeDefined() 214 expect(response.status).toBe(201) 215 }) 216 217 it('preserves original request headers', async () => { 218 const mockResponse = new Response('OK', { status: 200 }) 219 mockFetch.mockResolvedValue(mockResponse) 220 221 const request = new Request('http://localhost/api/proxy/api/v1/data', { 222 method: 'GET', 223 headers: { 224 'Content-Type': 'application/json', 225 Accept: 'application/json', 226 'X-Custom-Header': 'custom-value', 227 'Accept-Language': 'en-US', 228 }, 229 }) 230 231 await createHandler({ 232 params: { path: 'api/v1/data' }, 233 request, 234 locals: createAuthenticatedLocals('token', 'coves.social'), 235 fetch: mockFetch, 236 }) 237 238 const [, options] = getLastFetchCall() 239 expect(options.headers.get('Accept')).toBe('application/json') 240 expect(options.headers.get('X-Custom-Header')).toBe('custom-value') 241 expect(options.headers.get('Accept-Language')).toBe('en-US') 242 }) 243 244 it('returns response from upstream', async () => { 245 const responseData = { posts: [{ id: 1, title: 'Test' }] } 246 const mockResponse = new Response(JSON.stringify(responseData), { 247 status: 200, 248 headers: { 249 'Content-Type': 'application/json', 250 'X-Request-Id': 'req-123', 251 }, 252 }) 253 mockFetch.mockResolvedValue(mockResponse) 254 255 const request = new Request('http://localhost/api/proxy/api/v1/posts', { 256 method: 'GET', 257 }) 258 259 const response = await createHandler({ 260 params: { path: 'api/v1/posts' }, 261 request, 262 locals: createAuthenticatedLocals('token', 'coves.social'), 263 fetch: mockFetch, 264 }) 265 266 expect(response.status).toBe(200) 267 expect(response.headers.get('X-Request-Id')).toBe('req-123') 268 const body = await response.json() 269 expect(body).toEqual(responseData) 270 }) 271 }) 272 273 describe('unauthenticated requests', () => { 274 it('forwards request without Authorization header when no session', async () => { 275 const mockResponse = new Response(JSON.stringify({ public: true }), { 276 status: 200, 277 }) 278 mockFetch.mockResolvedValue(mockResponse) 279 280 const request = new Request('http://localhost/api/proxy/api/v1/public', { 281 method: 'GET', 282 }) 283 284 await createHandler({ 285 params: { path: 'api/v1/public' }, 286 request, 287 locals: createUnauthenticatedLocals(), 288 fetch: mockFetch, 289 }) 290 291 const [url, options] = getLastFetchCall() 292 expect(url).toBe('https://coves.social/api/v1/public') // Uses default instance 293 expect(options.headers.has('Authorization')).toBe(false) 294 }) 295 296 it('allows public endpoints without auth', async () => { 297 const mockResponse = new Response(JSON.stringify({ site: 'info' }), { 298 status: 200, 299 }) 300 mockFetch.mockResolvedValue(mockResponse) 301 302 const request = new Request('http://localhost/api/proxy/api/v1/site', { 303 method: 'GET', 304 }) 305 306 const response = await createHandler({ 307 params: { path: 'api/v1/site' }, 308 request, 309 locals: createUnauthenticatedLocals(), 310 fetch: mockFetch, 311 }) 312 313 expect(response.status).toBe(200) 314 const body = await response.json() 315 expect(body.site).toBe('info') 316 }) 317 }) 318 319 describe('error handling', () => { 320 it('returns 502 on upstream connection error', async () => { 321 mockFetch.mockRejectedValue(new Error('Connection refused')) 322 323 const request = new Request('http://localhost/api/proxy/api/v1/data', { 324 method: 'GET', 325 }) 326 327 const response = await createHandler({ 328 params: { path: 'api/v1/data' }, 329 request, 330 locals: createAuthenticatedLocals('token', 'coves.social'), 331 fetch: mockFetch, 332 }) 333 334 expect(response.status).toBe(502) 335 const body = await response.json() 336 expect(body.error).toBe('Bad Gateway') 337 }) 338 339 it('passes through upstream error responses', async () => { 340 const errorResponse = new Response( 341 JSON.stringify({ error: 'Not Found', message: 'Post not found' }), 342 { 343 status: 404, 344 headers: { 'Content-Type': 'application/json' }, 345 }, 346 ) 347 mockFetch.mockResolvedValue(errorResponse) 348 349 const request = new Request( 350 'http://localhost/api/proxy/api/v1/posts/999', 351 { 352 method: 'GET', 353 }, 354 ) 355 356 const response = await createHandler({ 357 params: { path: 'api/v1/posts/999' }, 358 request, 359 locals: createAuthenticatedLocals('token', 'coves.social'), 360 fetch: mockFetch, 361 }) 362 363 expect(response.status).toBe(404) 364 const body = await response.json() 365 expect(body.error).toBe('Not Found') 366 }) 367 368 it('handles 401 responses from upstream', async () => { 369 const errorResponse = new Response( 370 JSON.stringify({ error: 'Unauthorized' }), 371 { status: 401 }, 372 ) 373 mockFetch.mockResolvedValue(errorResponse) 374 375 const request = new Request( 376 'http://localhost/api/proxy/api/v1/protected', 377 { 378 method: 'GET', 379 }, 380 ) 381 382 const response = await createHandler({ 383 params: { path: 'api/v1/protected' }, 384 request, 385 locals: createAuthenticatedLocals('expired-token', 'coves.social'), 386 fetch: mockFetch, 387 }) 388 389 expect(response.status).toBe(401) 390 }) 391 392 it('handles 500 responses from upstream', async () => { 393 const errorResponse = new Response( 394 JSON.stringify({ error: 'Internal Server Error' }), 395 { status: 500 }, 396 ) 397 mockFetch.mockResolvedValue(errorResponse) 398 399 const request = new Request('http://localhost/api/proxy/api/v1/error', { 400 method: 'GET', 401 }) 402 403 const response = await createHandler({ 404 params: { path: 'api/v1/error' }, 405 request, 406 locals: createUnauthenticatedLocals(), 407 fetch: mockFetch, 408 }) 409 410 expect(response.status).toBe(500) 411 }) 412 }) 413 414 describe('header handling', () => { 415 it('removes host header before forwarding', async () => { 416 const mockResponse = new Response('OK', { status: 200 }) 417 mockFetch.mockResolvedValue(mockResponse) 418 419 const request = new Request('http://localhost/api/proxy/api/v1/data', { 420 method: 'GET', 421 headers: { 422 Host: 'localhost:5173', 423 }, 424 }) 425 426 await createHandler({ 427 params: { path: 'api/v1/data' }, 428 request, 429 locals: createAuthenticatedLocals('token', 'coves.social'), 430 fetch: mockFetch, 431 }) 432 433 const [, options] = getLastFetchCall() 434 expect(options.headers.has('Host')).toBe(false) 435 }) 436 437 it('removes content-encoding from response', async () => { 438 const mockResponse = new Response('compressed data', { 439 status: 200, 440 headers: { 441 'Content-Encoding': 'gzip', 442 'Content-Type': 'application/json', 443 }, 444 }) 445 mockFetch.mockResolvedValue(mockResponse) 446 447 const request = new Request('http://localhost/api/proxy/api/v1/data', { 448 method: 'GET', 449 }) 450 451 const response = await createHandler({ 452 params: { path: 'api/v1/data' }, 453 request, 454 locals: createUnauthenticatedLocals(), 455 fetch: mockFetch, 456 }) 457 458 expect(response.headers.has('Content-Encoding')).toBe(false) 459 expect(response.headers.get('Content-Type')).toBe('application/json') 460 }) 461 }) 462 463 describe('HTTP methods', () => { 464 it('handles PUT requests', async () => { 465 const mockResponse = new Response(JSON.stringify({ updated: true }), { 466 status: 200, 467 }) 468 mockFetch.mockResolvedValue(mockResponse) 469 470 const request = new Request('http://localhost/api/proxy/api/v1/posts/1', { 471 method: 'PUT', 472 headers: { 'Content-Type': 'application/json' }, 473 body: JSON.stringify({ title: 'Updated' }), 474 }) 475 476 const response = await createHandler({ 477 params: { path: 'api/v1/posts/1' }, 478 request, 479 locals: createAuthenticatedLocals('token', 'coves.social'), 480 fetch: mockFetch, 481 }) 482 483 const [, options] = getLastFetchCall() 484 expect(options.method).toBe('PUT') 485 expect(response.status).toBe(200) 486 }) 487 488 it('handles DELETE requests', async () => { 489 const mockResponse = new Response(null, { status: 204 }) 490 mockFetch.mockResolvedValue(mockResponse) 491 492 const request = new Request('http://localhost/api/proxy/api/v1/posts/1', { 493 method: 'DELETE', 494 }) 495 496 const response = await createHandler({ 497 params: { path: 'api/v1/posts/1' }, 498 request, 499 locals: createAuthenticatedLocals('token', 'coves.social'), 500 fetch: mockFetch, 501 }) 502 503 const [, options] = getLastFetchCall() 504 expect(options.method).toBe('DELETE') 505 expect(response.status).toBe(204) 506 }) 507 508 it('handles PATCH requests', async () => { 509 const mockResponse = new Response(JSON.stringify({ patched: true }), { 510 status: 200, 511 }) 512 mockFetch.mockResolvedValue(mockResponse) 513 514 const request = new Request('http://localhost/api/proxy/api/v1/posts/1', { 515 method: 'PATCH', 516 headers: { 'Content-Type': 'application/json' }, 517 body: JSON.stringify({ title: 'Patched' }), 518 }) 519 520 const response = await createHandler({ 521 params: { path: 'api/v1/posts/1' }, 522 request, 523 locals: createAuthenticatedLocals('token', 'coves.social'), 524 fetch: mockFetch, 525 }) 526 527 const [, options] = getLastFetchCall() 528 expect(options.method).toBe('PATCH') 529 expect(response.status).toBe(200) 530 }) 531 }) 532 533 describe('instance routing', () => { 534 it('uses active account instance when available', async () => { 535 const mockResponse = new Response('OK', { status: 200 }) 536 mockFetch.mockResolvedValue(mockResponse) 537 538 const request = new Request('http://localhost/api/proxy/api/v1/data', { 539 method: 'GET', 540 }) 541 542 await createHandler({ 543 params: { path: 'api/v1/data' }, 544 request, 545 locals: createAuthenticatedLocals('token', 'custom.instance.com'), 546 fetch: mockFetch, 547 }) 548 549 const [url] = getLastFetchCall() 550 expect(url).toBe('https://custom.instance.com/api/v1/data') 551 }) 552 553 it('falls back to default instance when no active account', async () => { 554 const mockResponse = new Response('OK', { status: 200 }) 555 mockFetch.mockResolvedValue(mockResponse) 556 557 const request = new Request('http://localhost/api/proxy/api/v1/data', { 558 method: 'GET', 559 }) 560 561 await createHandler({ 562 params: { path: 'api/v1/data' }, 563 request, 564 locals: createUnauthenticatedLocals(), 565 fetch: mockFetch, 566 }) 567 568 const [url] = getLastFetchCall() 569 expect(url).toBe('https://coves.social/api/v1/data') 570 }) 571 }) 572 573 describe('path traversal security', () => { 574 it('rejects paths with ../ traversal attempts', async () => { 575 const request = new Request( 576 'http://localhost/api/proxy/../../../etc/passwd', 577 { 578 method: 'GET', 579 }, 580 ) 581 582 const response = await createHandler({ 583 params: { path: '../../../etc/passwd' }, 584 request, 585 locals: createUnauthenticatedLocals(), 586 fetch: mockFetch, 587 }) 588 589 // Should return 400 Bad Request and NOT call fetch 590 expect(response.status).toBe(400) 591 expect(mockFetch).not.toHaveBeenCalled() 592 const body = await response.json() 593 expect(body.error).toBe('Bad Request') 594 expect(body.message).toContain('Invalid path') 595 }) 596 597 it('rejects URL-encoded traversal attempts (..%2F)', async () => { 598 // Note: SvelteKit typically decodes this, but we test the decoded version 599 const request = new Request( 600 'http://localhost/api/proxy/..%2F..%2Fetc%2Fpasswd', 601 { 602 method: 'GET', 603 }, 604 ) 605 606 const response = await createHandler({ 607 params: { path: '../../etc/passwd' }, // Decoded by SvelteKit 608 request, 609 locals: createUnauthenticatedLocals(), 610 fetch: mockFetch, 611 }) 612 613 expect(response.status).toBe(400) 614 expect(mockFetch).not.toHaveBeenCalled() 615 }) 616 617 it('rejects paths with encoded traversal in the middle', async () => { 618 const request = new Request( 619 'http://localhost/api/proxy/api/v1/../../../etc/passwd', 620 { 621 method: 'GET', 622 }, 623 ) 624 625 const response = await createHandler({ 626 params: { path: 'api/v1/../../../etc/passwd' }, 627 request, 628 locals: createUnauthenticatedLocals(), 629 fetch: mockFetch, 630 }) 631 632 expect(response.status).toBe(400) 633 expect(mockFetch).not.toHaveBeenCalled() 634 }) 635 636 it('rejects paths with backslash traversal (Windows-style)', async () => { 637 const request = new Request( 638 'http://localhost/api/proxy/..\\..\\etc\\passwd', 639 { 640 method: 'GET', 641 }, 642 ) 643 644 const response = await createHandler({ 645 params: { path: '..\\..\\etc\\passwd' }, 646 request, 647 locals: createUnauthenticatedLocals(), 648 fetch: mockFetch, 649 }) 650 651 expect(response.status).toBe(400) 652 expect(mockFetch).not.toHaveBeenCalled() 653 }) 654 655 it('rejects paths with mixed traversal techniques', async () => { 656 const request = new Request( 657 'http://localhost/api/proxy/api/../v1/../../secret', 658 { 659 method: 'GET', 660 }, 661 ) 662 663 const response = await createHandler({ 664 params: { path: 'api/../v1/../../secret' }, 665 request, 666 locals: createUnauthenticatedLocals(), 667 fetch: mockFetch, 668 }) 669 670 expect(response.status).toBe(400) 671 expect(mockFetch).not.toHaveBeenCalled() 672 }) 673 674 it('rejects double-encoded traversal attempts (..%252F)', async () => { 675 // Double-encoded: %25 = %, so ..%252F = ..%2F when decoded once 676 // We need to check if the path contains %2F or similar encoded sequences 677 const request = new Request( 678 'http://localhost/api/proxy/..%252F..%252Fetc', 679 { 680 method: 'GET', 681 }, 682 ) 683 684 const response = await createHandler({ 685 params: { path: '..%2F..%2Fetc' }, // SvelteKit decodes once 686 request, 687 locals: createUnauthenticatedLocals(), 688 fetch: mockFetch, 689 }) 690 691 expect(response.status).toBe(400) 692 expect(mockFetch).not.toHaveBeenCalled() 693 }) 694 695 it('rejects paths with null bytes', async () => { 696 const request = new Request( 697 'http://localhost/api/proxy/api/v1/data%00.json', 698 { 699 method: 'GET', 700 }, 701 ) 702 703 const response = await createHandler({ 704 params: { path: 'api/v1/data\x00.json' }, 705 request, 706 locals: createUnauthenticatedLocals(), 707 fetch: mockFetch, 708 }) 709 710 expect(response.status).toBe(400) 711 expect(mockFetch).not.toHaveBeenCalled() 712 }) 713 714 it('allows legitimate paths with dots in filenames', async () => { 715 const mockResponse = new Response('OK', { status: 200 }) 716 mockFetch.mockResolvedValue(mockResponse) 717 718 const request = new Request( 719 'http://localhost/api/proxy/api/v1/file.json', 720 { 721 method: 'GET', 722 }, 723 ) 724 725 const response = await createHandler({ 726 params: { path: 'api/v1/file.json' }, 727 request, 728 locals: createUnauthenticatedLocals(), 729 fetch: mockFetch, 730 }) 731 732 expect(response.status).toBe(200) 733 expect(mockFetch).toHaveBeenCalledTimes(1) 734 const [url] = getLastFetchCall() 735 expect(url).toBe('https://coves.social/api/v1/file.json') 736 }) 737 738 it('allows paths with single dots (current directory)', async () => { 739 const mockResponse = new Response('OK', { status: 200 }) 740 mockFetch.mockResolvedValue(mockResponse) 741 742 const request = new Request('http://localhost/api/proxy/api/./v1/data', { 743 method: 'GET', 744 }) 745 746 // Single dots are safe but we normalize them 747 const response = await createHandler({ 748 params: { path: 'api/./v1/data' }, 749 request, 750 locals: createUnauthenticatedLocals(), 751 fetch: mockFetch, 752 }) 753 754 expect(response.status).toBe(200) 755 expect(mockFetch).toHaveBeenCalled() 756 }) 757 758 it('allows paths with dots in domain-like segments', async () => { 759 const mockResponse = new Response('OK', { status: 200 }) 760 mockFetch.mockResolvedValue(mockResponse) 761 762 const request = new Request( 763 'http://localhost/api/proxy/api/v1/users/user.name@domain.com', 764 { 765 method: 'GET', 766 }, 767 ) 768 769 const response = await createHandler({ 770 params: { path: 'api/v1/users/user.name@domain.com' }, 771 request, 772 locals: createUnauthenticatedLocals(), 773 fetch: mockFetch, 774 }) 775 776 expect(response.status).toBe(200) 777 expect(mockFetch).toHaveBeenCalled() 778 }) 779 780 it('rejects paths that would escape the API root after normalization', async () => { 781 const request = new Request( 782 'http://localhost/api/proxy/api/v1/../../../../root', 783 { 784 method: 'GET', 785 }, 786 ) 787 788 const response = await createHandler({ 789 params: { path: 'api/v1/../../../../root' }, 790 request, 791 locals: createUnauthenticatedLocals(), 792 fetch: mockFetch, 793 }) 794 795 expect(response.status).toBe(400) 796 expect(mockFetch).not.toHaveBeenCalled() 797 }) 798 799 it('rejects paths with protocol injection attempts', async () => { 800 const request = new Request( 801 'http://localhost/api/proxy/http://evil.com/malicious', 802 { 803 method: 'GET', 804 }, 805 ) 806 807 const response = await createHandler({ 808 params: { path: 'http://evil.com/malicious' }, 809 request, 810 locals: createUnauthenticatedLocals(), 811 fetch: mockFetch, 812 }) 813 814 expect(response.status).toBe(400) 815 expect(mockFetch).not.toHaveBeenCalled() 816 }) 817 818 it('rejects paths with javascript protocol', async () => { 819 const request = new Request( 820 'http://localhost/api/proxy/javascript:alert(1)', 821 { 822 method: 'GET', 823 }, 824 ) 825 826 const response = await createHandler({ 827 params: { path: 'javascript:alert(1)' }, 828 request, 829 locals: createUnauthenticatedLocals(), 830 fetch: mockFetch, 831 }) 832 833 expect(response.status).toBe(400) 834 expect(mockFetch).not.toHaveBeenCalled() 835 }) 836 }) 837 838 describe('production HTTP rejection', () => { 839 /** 840 * Note: The actual production HTTP rejection is handled in the server endpoint 841 * using import.meta.env.PROD check. This test documents the expected behavior 842 * and tests the validation logic in isolation. 843 * 844 * In production, HTTP URLs should return 400 Bad Request with a clear message. 845 */ 846 it('documents production HTTP URL rejection behavior', () => { 847 // The actual server implementation checks import.meta.env.PROD 848 // and rejects HTTP URLs with this message: 849 const expectedErrorMessage = 'HTTP URLs are not allowed in production' 850 851 // This is a documentation test showing what the production behavior should be 852 expect(expectedErrorMessage).toBe( 853 'HTTP URLs are not allowed in production', 854 ) 855 856 // The handler in +server.ts lines 82-93 implements: 857 // if (import.meta.env.PROD && baseUrl.startsWith('http://')) { 858 // return new Response( 859 // JSON.stringify({ 860 // error: 'Bad Request', 861 // message: 'HTTP URLs are not allowed in production', 862 // }), 863 // { status: 400, headers: { 'Content-Type': 'application/json' } } 864 // ) 865 // } 866 }) 867 868 it('allows HTTP URLs in development/test environment', async () => { 869 // In non-production environment, HTTP URLs are allowed for local development 870 const mockResponse = new Response('OK', { status: 200 }) 871 mockFetch.mockResolvedValue(mockResponse) 872 873 const request = new Request('http://localhost/api/proxy/api/v1/data', { 874 method: 'GET', 875 }) 876 877 // Create handler with HTTP instance 878 const response = await createHandler({ 879 params: { path: 'api/v1/data' }, 880 request, 881 locals: createAuthenticatedLocals('token', 'http://localhost:8080'), 882 fetch: mockFetch, 883 }) 884 885 // In test/dev, HTTP should work 886 // Note: createHandler uses https:// prefix, so this tests the handler accepts 887 // the request. The actual +server.ts implementation handles HTTP instances. 888 expect(response.status).toBe(200) 889 }) 890 }) 891})