diff --git a/services/nginx/app/classes/cors_policy.php b/services/nginx/app/classes/cors_policy.php index 1866668a..e9aebb2b 100644 --- a/services/nginx/app/classes/cors_policy.php +++ b/services/nginx/app/classes/cors_policy.php @@ -29,6 +29,7 @@ class cors_policy 'http://localhost:5174', 'http://127.0.0.1:5173', 'http://127.0.0.1:5174', + 'capacitor://localhost', ]; public static function normalizeOrigin(?string $value): string @@ -38,7 +39,7 @@ class cors_policy return $value; } - if (preg_match('#^https?://#i', $value) !== 1) { + if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) { return ''; } @@ -48,7 +49,7 @@ class cors_policy } $scheme = strtolower((string)$parts['scheme']); - if (!in_array($scheme, ['http', 'https'], true)) { + if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) { return ''; } @@ -58,6 +59,27 @@ class cors_policy return $scheme . '://' . $host . $port; } + public static function normalizeRequestOrigin(?string $value): string + { + $value = trim((string)$value); + if ($value === '' || $value === '*') { + return ''; + } + + $parts = parse_url($value); + if (!is_array($parts)) { + return ''; + } + + foreach (['user', 'pass', 'path', 'query', 'fragment'] as $disallowedPart) { + if (array_key_exists($disallowedPart, $parts)) { + return ''; + } + } + + return self::normalizeOrigin($value); + } + /** * @return array */ @@ -105,8 +127,8 @@ class cors_policy public static function isOriginAllowed(?string $origin, string $corsConfig): bool { - $origin = self::normalizeOrigin($origin); - if ($origin === '' || $origin === '*') { + $origin = self::normalizeRequestOrigin($origin); + if ($origin === '') { return false; } @@ -119,7 +141,7 @@ class cors_policy */ public static function responseHeaders(?string $origin, string $corsConfig): array { - $origin = self::normalizeOrigin($origin); + $origin = self::normalizeRequestOrigin($origin); if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) { return []; } diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php index e6d42ccb..f7f1bd04 100644 --- a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php @@ -11,6 +11,10 @@ it('normalizes URL-like CORS entries to origins', function (): void { ->toBe('https://api-v2.truckwash.io'); expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping')) ->toBe('https://api.truckwash.io:4433'); + expect(cors_policy::normalizeOrigin('capacitor://LOCALHOST/index.html')) + ->toBe('capacitor://localhost'); + expect(cors_policy::normalizeOrigin('ionic://localhost')) + ->toBe(''); }); it('merges required release and existing frontend origins into configured CORS', function (): void { @@ -18,10 +22,29 @@ it('merges required release and existing frontend origins into configured CORS', expect($origins)->toContain('https://api-v2.truckwash.io'); expect($origins)->toContain('http://localhost:5173'); + expect($origins)->toContain('capacitor://localhost'); expect($origins)->toContain('https://truckwash.io'); expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api'); }); +it('requires a syntactically exact request origin', function (): void { + expect(cors_policy::normalizeRequestOrigin('capacitor://localhost')) + ->toBe('capacitor://localhost'); + expect(cors_policy::normalizeRequestOrigin('https://truckwash.io')) + ->toBe('https://truckwash.io'); + + foreach ([ + 'capacitor://user@localhost', + 'capacitor://localhost/', + 'capacitor://localhost/path', + 'capacitor://localhost?query=1', + 'capacitor://localhost#fragment', + ] as $invalidOrigin) { + expect(cors_policy::normalizeRequestOrigin($invalidOrigin))->toBe(''); + expect(cors_policy::preflightResponse($invalidOrigin, 'https://truckwash.io')['status'])->toBe(403); + } +}); + it('builds credential-safe normal CORS response headers for allowed origins', function (): void { $headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io'); @@ -44,9 +67,23 @@ it('allows the fallback Vite localhost dev origin used after port 5173 is busy', expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('http://localhost:5174'); }); -it('builds preflight CORS response headers for api-v2 release URLs', function (): void { +it('allows the exact Capacitor iOS application origin', function (): void { + $preflight = cors_policy::preflightResponse('capacitor://localhost', 'https://truckwash.io'); + + expect($preflight['allowed'])->toBeTrue(); + expect($preflight['status'])->toBe(200); + expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('capacitor://localhost'); + expect($preflight['headers']['Access-Control-Allow-Credentials'])->toBe('true'); + expect($preflight['headers']['Access-Control-Allow-Methods'])->toContain('POST'); + expect($preflight['headers']['Access-Control-Allow-Headers'])->toContain('Authorization'); + expect($preflight['headers']['Access-Control-Allow-Headers'])->toContain('Content-Type'); + expect($preflight['headers']['Vary'])->toBe('Origin'); + expect($preflight['body'])->toBe(''); +}); + +it('builds preflight CORS response headers for the api-v2 release origin', function (): void { $preflight = cors_policy::preflightResponse( - 'https://api-v2.truckwash.io/master/api', + 'https://api-v2.truckwash.io', 'https://truckwash.io' ); @@ -67,6 +104,13 @@ it('rejects unknown CORS origins', function (): void { expect($preflight['allowed'])->toBeFalse(); expect($preflight['status'])->toBe(403); expect($preflight['body'])->toContain('CORS origin not allowed'); + + $unknownCapacitorOrigin = cors_policy::preflightResponse( + 'capacitor://other-host', + 'https://truckwash.io' + ); + expect($unknownCapacitorOrigin['allowed'])->toBeFalse(); + expect($unknownCapacitorOrigin['status'])->toBe(403); }); it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void {