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:
Jeppe B
2026-07-20 13:53:55 +02:00
committed by GitHub
parent b177347bf5
commit abde54c898
2 changed files with 73 additions and 7 deletions
+27 -5
View File
@@ -29,6 +29,7 @@ class cors_policy
'http://localhost:5174', 'http://localhost:5174',
'http://127.0.0.1:5173', 'http://127.0.0.1:5173',
'http://127.0.0.1:5174', 'http://127.0.0.1:5174',
'capacitor://localhost',
]; ];
public static function normalizeOrigin(?string $value): string public static function normalizeOrigin(?string $value): string
@@ -38,7 +39,7 @@ class cors_policy
return $value; return $value;
} }
if (preg_match('#^https?://#i', $value) !== 1) { if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
return ''; return '';
} }
@@ -48,7 +49,7 @@ class cors_policy
} }
$scheme = strtolower((string)$parts['scheme']); $scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) { if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
return ''; return '';
} }
@@ -58,6 +59,27 @@ class cors_policy
return $scheme . '://' . $host . $port; 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> * @return array<int,string>
*/ */
@@ -105,8 +127,8 @@ class cors_policy
public static function isOriginAllowed(?string $origin, string $corsConfig): bool public static function isOriginAllowed(?string $origin, string $corsConfig): bool
{ {
$origin = self::normalizeOrigin($origin); $origin = self::normalizeRequestOrigin($origin);
if ($origin === '' || $origin === '*') { if ($origin === '') {
return false; return false;
} }
@@ -119,7 +141,7 @@ class cors_policy
*/ */
public static function responseHeaders(?string $origin, string $corsConfig): array public static function responseHeaders(?string $origin, string $corsConfig): array
{ {
$origin = self::normalizeOrigin($origin); $origin = self::normalizeRequestOrigin($origin);
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) { if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
return []; return [];
} }
@@ -11,6 +11,10 @@ it('normalizes URL-like CORS entries to origins', function (): void {
->toBe('https://api-v2.truckwash.io'); ->toBe('https://api-v2.truckwash.io');
expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping')) expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping'))
->toBe('https://api.truckwash.io:4433'); ->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 { 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('https://api-v2.truckwash.io');
expect($origins)->toContain('http://localhost:5173'); expect($origins)->toContain('http://localhost:5173');
expect($origins)->toContain('capacitor://localhost');
expect($origins)->toContain('https://truckwash.io'); expect($origins)->toContain('https://truckwash.io');
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api'); 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 { it('builds credential-safe normal CORS response headers for allowed origins', function (): void {
$headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io'); $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'); 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( $preflight = cors_policy::preflightResponse(
'https://api-v2.truckwash.io/master/api', 'https://api-v2.truckwash.io',
'https://truckwash.io' 'https://truckwash.io'
); );
@@ -67,6 +104,13 @@ it('rejects unknown CORS origins', function (): void {
expect($preflight['allowed'])->toBeFalse(); expect($preflight['allowed'])->toBeFalse();
expect($preflight['status'])->toBe(403); expect($preflight['status'])->toBe(403);
expect($preflight['body'])->toContain('CORS origin not allowed'); 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 { it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void {