Skip to content

Commit 6668873

Browse files
committed
fix(oauth2): validate redirect_uris scheme/uniqueness on client create, not just update
CodeRabbit PR review (#147): ClientService::create() validated allowed_origins/post_logout_redirect_uris for Native clients but never redirect_uris - a create payload could register a dangerous scheme (javascript://, etc.) or a scheme already claimed by another client through redirect_uris, only ever caught later by the runtime isUriAllowed() gate rather than at write time. assertNativeCustomSchemesAllowed() already ran both the deny-list check and the cross-client uniqueness check generically per field (since the earlier hardening pass); extending its field list to include redirect_uris closes the gap for both create() and update() via one shared method. This also let update()'s separate, by-then fully-duplicate inline redirect_uris validation loop be deleted. Verified: 156 tests / 0 failures. Plan: docs/plans/2026-07-14-native-clients-custom-schemes.md (Task 9) ADR: docs/adr/0001-native-client-custom-uri-schemes.md
1 parent 580f3fb commit 6668873

3 files changed

Lines changed: 70 additions & 35 deletions

File tree

app/Services/OAuth2/ClientService.php

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -222,20 +222,21 @@ public function getCurrentClientAuthInfo()
222222

223223
/**
224224
* Native clients may register genuine custom app URI schemes (e.g. myapp://, com.example.app://) in
225-
* allowed_origins and post_logout_redirect_uris. They may NOT register plain http:// outside the RFC 8252
226-
* loopback carve-out, nor dangerous/launch pseudo-schemes (javascript:, data:, intent:, ...): at
227-
* end-session these fields become live 302 redirect targets. See IClient::DISALLOWED_NATIVE_URI_SCHEMES
228-
* for the deny-list (shared with the runtime allow-gates in Client::isUriAllowed/isPostLogoutUriAllowed).
229-
* Also enforces the same cross-client scheme-uniqueness rule redirect_uris already has, since a scheme
230-
* claimed by another client here creates the identical OS-level interception risk.
225+
* redirect_uris, allowed_origins, and post_logout_redirect_uris. They may NOT register plain http://
226+
* outside the RFC 8252 loopback carve-out, nor dangerous/launch pseudo-schemes (javascript:, data:,
227+
* intent:, ...): redirect_uris carries the authorization code, and the other two fields become live
228+
* 302 redirect targets at end-session. See IClient::DISALLOWED_NATIVE_URI_SCHEMES for the deny-list
229+
* (shared with the runtime allow-gates in Client::isUriAllowed/isPostLogoutUriAllowed). Also enforces
230+
* cross-client scheme uniqueness: a scheme claimed by another client in any of these fields creates the
231+
* same OS-level interception risk regardless of which field either client used it in.
231232
*
232233
* @param array $payload
233234
* @param int $exclude_client_id the client being written; -1 (never a real id) when creating a new one
234235
* @throws ValidationException
235236
*/
236237
private function assertNativeCustomSchemesAllowed(array $payload, int $exclude_client_id = -1): void
237238
{
238-
foreach (['allowed_origins', 'post_logout_redirect_uris'] as $field) {
239+
foreach (['redirect_uris', 'allowed_origins', 'post_logout_redirect_uris'] as $field) {
239240
if (empty($payload[$field])) continue;
240241
foreach (explode(',', $payload[$field]) as $uri) {
241242
$parts = @parse_url(trim($uri));
@@ -272,8 +273,8 @@ public function create(array $payload):IEntity
272273
throw new ValidationException('there is already another application with that name, please choose another one.');
273274
}
274275

275-
// close the create-path bypass: the same scheme allow-list update() enforces (only reachable
276-
// for native clients, where the runtime https gate is relaxed).
276+
// same scheme deny-list + cross-client uniqueness rule update() enforces (only reachable
277+
// for native clients, where the runtime https gate is relaxed) - now covers redirect_uris too.
277278
if (($payload['application_type'] ?? null) === IClient::ApplicationType_Native) {
278279
$this->assertNativeCustomSchemesAllowed($payload);
279280
}
@@ -337,31 +338,9 @@ public function update(int $id, array $payload):IEntity
337338
// validate uris
338339
switch($client->getApplicationType()) {
339340
case IClient::ApplicationType_Native: {
340-
341-
if (isset($payload['redirect_uris'])) {
342-
$redirect_uris = explode(',', $payload['redirect_uris']);
343-
//check that custom schema does not already exists for another registerd app
344-
if (!empty($payload['redirect_uris'])) {
345-
foreach ($redirect_uris as $uri) {
346-
$uri = @parse_url($uri);
347-
if (!isset($uri['scheme'])) {
348-
throw new ValidationException('invalid scheme on redirect uri.');
349-
}
350-
if (Client::isDisallowedNativeUriScheme($uri['scheme'], $uri['host'] ?? null)) {
351-
throw new ValidationException(sprintf('scheme %s:// is not allowed.', $uri['scheme']));
352-
}
353-
// the else branch previously here (rejecting non-http(s) "non-custom" schemes)
354-
// is unreachable now: ftp/file and non-loopback http are already rejected by
355-
// the deny-list check above, and https/loopback-http both satisfy isHttpSchema.
356-
if (HttpUtils::isCustomSchema($uri['scheme'])
357-
&& $this->client_repository->hasCustomSchemeRegisteredOnAnotherClientThan($id, $uri['scheme'])) {
358-
throw new ValidationException(sprintf('schema %s:// already registered for another client.',
359-
$uri['scheme']));
360-
}
361-
}
362-
}
363-
}
364-
341+
// redirect_uris, allowed_origins, and post_logout_redirect_uris all share the same
342+
// scheme deny-list + cross-client uniqueness rule; assertNativeCustomSchemesAllowed
343+
// validates whichever of the three are present in the payload.
365344
$this->assertNativeCustomSchemesAllowed($payload, $id);
366345
}
367346
break;

docs/adr/0001-native-client-custom-uri-schemes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ Four consecutive adversarial code-review passes (xhigh-effort, multi-agent) surf
5454

5555
**Corrected during review (not a trade-off — fixed before merge):**
5656
- The deny-list/loopback-host constants were initially placed on `Utils\Http\HttpUtils` (a generic scheme-classification helper) and hand-duplicated as a second literal array in `logout_options.js`. Both were flagged in PR review: the frontend duplication as an unmaintained "keep in sync" liability, and the `HttpUtils` placement as the wrong dependency direction (a generic utility class encoding OAuth2-Native-client domain policy). Relocated to `IClient` (data) + `Client` (predicate logic) and wired the admin UI to read the values from the backend at render time instead of maintaining its own copy.
57+
- `ClientService::create()` initially never validated `redirect_uris` scheme or cross-client uniqueness at all — originally accepted as a trade-off (mitigated by the runtime allow-gate). CodeRabbit's automated PR review re-flagged it; by that point `assertNativeCustomSchemesAllowed()` already did both checks generically per field, so closing the gap was a two-line change (add `redirect_uris` to its field list) that additionally let `update()`'s separate, now-fully-duplicate inline `redirect_uris` loop be deleted (~25 lines). No longer a trade-off — `create()` and `update()` now enforce identically for all three fields via one shared method.
5758

5859
**Accepted trade-offs (not fixed in this change, documented for a future pass if warranted):**
59-
- `ClientService::create()` still never validates `redirect_uris` scheme or cross-client uniqueness at all (a pre-existing gap, unrelated to the deny-list itself) — mitigated by the runtime allow-gate rejecting a dangerous scheme regardless of how it reached storage, but write-time hygiene on that one path remains weaker than `update()`.
6060
- The deny-list can never be exhaustive against every OS/browser/app-launcher scheme that might exist now or in the future (a structural property of any blocklist). A `search-ms://`-style scheme not yet on the list would be accepted. Closing this fully requires an allow-list architecture, a larger change than this ADR's scope.
6161

6262
## References

tests/ClientApiTest.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,62 @@ public function testCreateNativeClientRejectsDangerousSchemeOnPostLogout(){
272272
$this->assertResponseStatus(412);
273273
}
274274

275+
public function testCreateNativeClientRejectsDangerousSchemeOnRedirectUris(){
276+
277+
// CodeRabbit PR #147 finding: create() validated allowed_origins/post_logout_redirect_uris for
278+
// dangerous schemes but never redirect_uris - a create payload could register javascript:// etc.
279+
// there and it would only ever be caught later by the runtime isUriAllowed() gate, not at write time.
280+
$user = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
281+
282+
$data = array(
283+
'user_id' => $user->id,
284+
'app_name' => 'native_dangerous_redirect_uri_app',
285+
'app_description' => 'native app with dangerous scheme on redirect_uris',
286+
'application_type' => IClient::ApplicationType_Native,
287+
'redirect_uris' => 'javascript://x%0aalert(1)',
288+
);
289+
290+
$response = $this->action("POST", "Api\\ClientApiController@create",
291+
$data,
292+
[],
293+
[],
294+
[]);
295+
296+
$this->assertResponseStatus(412);
297+
}
298+
299+
public function testCreateNativeClientRejectsRedirectUriSchemeAlreadyRegisteredByAnotherClient(){
300+
301+
// CodeRabbit PR #147 finding, cross-client uniqueness half: create() never checked redirect_uris
302+
// scheme collisions either (only update() did, via a separate now-removed inline loop).
303+
$existing = EntityManager::getRepository(Client::class)->findOneBy(['app_name' => 'oauth2_native_app']);
304+
$response = $this->action("PUT", "Api\\ClientApiController@update",
305+
array(
306+
'id' => $existing->id,
307+
'application_type' => IClient::ApplicationType_Native,
308+
'redirect_uris' => 'createuniqueness://callback',
309+
),
310+
[],
311+
[],
312+
[]);
313+
$this->assertResponseStatus(201);
314+
315+
$user = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
316+
$response = $this->action("POST", "Api\\ClientApiController@create",
317+
array(
318+
'user_id' => $user->id,
319+
'app_name' => 'native_duplicate_redirect_scheme_app',
320+
'app_description' => 'native app registering an already-claimed redirect_uris scheme',
321+
'application_type' => IClient::ApplicationType_Native,
322+
'redirect_uris' => 'createuniqueness://other',
323+
),
324+
[],
325+
[],
326+
[]);
327+
328+
$this->assertResponseStatus(412);
329+
}
330+
275331
public function testUpdateJsClientRejectsCustomSchemeOnPostLogoutUrisAndAllowedOrigins(){
276332

277333
$client = EntityManager::getRepository(Client::class)->findOneBy(['app_name' => 'oauth2_test_app_public_2']);

0 commit comments

Comments
 (0)