Allow Capacitor iOS API origin (#317)
Allow the exact Capacitor iOS WebView origin through credentialed CORS while strictly validating request-origin syntax.
This commit is contained in:
@@ -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<int,string>
|
||||
*/
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user