Skip to content

fix(oauth2): use exact matching for JS client origin validation#545

Open
mulldug wants to merge 1 commit into
mainfrom
fix/oauth2-exact-origin-matching
Open

fix(oauth2): use exact matching for JS client origin validation#545
mulldug wants to merge 1 commit into
mainfrom
fix/oauth2-exact-origin-matching

Conversation

@mulldug
Copy link
Copy Markdown
Collaborator

@mulldug mulldug commented May 13, 2026

ref: https://app.clickup.com/t/86b9xky5x

Summary

  • Replace str_contains substring check with in_array exact match in OAuth2BearerAccessTokenRequestValidator for JS_CLIENT origin validation
  • Splits the space-delimited allowed_origins string from the IDP introspection response into individual entries, trims each, and strips trailing slashes before comparing
  • Prevents crafted short origins (e.g. example) from satisfying the guard by matching as a substring of a legitimate allowed origin

Notes

This is a pre-existing bug, not introduced by recent work. Separated into its own PR to keep the security fix reviewable independently.

Test plan

  • JS client requests with a valid origin are accepted
  • JS client requests with an origin that is a substring of a valid origin (e.g. example when https://example.com is allowed) are rejected
  • JS client requests with no origin are rejected

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced OAuth2 Bearer token request validation with stricter origin matching. The validation now requires exact matches instead of substring-based checks, improving security for API requests.
  • Tests

    • Added comprehensive unit tests for OAuth2 Bearer token request validator covering origin validation scenarios.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

OAuth2 middleware origin validation is refactored from substring-based checking to exact-match membership testing with URL normalization. Allowed origins are parsed as space-delimited, normalized, and validated against request origins after trailing-slash trimming. Comprehensive unit tests verify acceptance of exact and normalized origins, rejection of bare hostnames and missing headers.

Changes

OAuth2 Bearer Token Origin Validation

Layer / File(s) Summary
Origin validation logic refactoring and test coverage
app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php, tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php
Middleware now parses allowed origins as space-delimited, normalizes via URL\Normalizer, and enforces strict exact-match membership; test file with TestableBearerValidator subclass verifies exact-URL acceptance, trailing-slash tolerance, bare-hostname rejection (403), and missing-header rejection (403) using mocked dependencies and stubbed logging.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A stricter validation dance so true,
Origins must match, no substring brew!
With normalized paths and spaces split wide,
The Bearer token flows with safer pride!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: shifting from substring-based to exact-match validation for OAuth2 JS client origin validation, which directly reflects the core modification in OAuth2BearerAccessTokenRequestValidator.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/oauth2-exact-origin-matching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-545/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
app/ModelSerializers/Summit/SummitSerializer.php (1)

335-342: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Log the full exception object, not just the message.

Same issue as the registration profile handler above.

🐛 Proposed fix
                 } catch (\Exception $ex) {
-                    Log::warning($ex->getMessage());
+                    Log::warning($ex);
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ModelSerializers/Summit/SummitSerializer.php` around lines 335 - 342, The
catch block around $build_default_payment_gateway_profile_strategy->build in
SummitSerializer:: (where you call SerializerRegistry::getInstance(),
$serializer->serialize and AbstractSerializer::filterExpandByPrefix) currently
logs only $ex->getMessage(); change it to log the full exception (message plus
stack trace) so you capture context—e.g., pass the exception object or its full
string/trace to Log::warning instead of only getMessage(); follow the same
pattern you used in the registration profile handler to ensure consistency.
🧹 Nitpick comments (3)
tests/oauth2/OAuth2SummitSpeakersApiTest.php (3)

1903-1936: ⚡ Quick win

Consider using assertGreaterThanOrEqual(0) for count assertions.

Line 1918 uses assertGreaterThan(0) which assumes test data always contains speaker activities. For consistency with the submitters tests and improved robustness, consider using assertGreaterThanOrEqual(0).

♻️ Recommended adjustment
         $this->assertResponseStatus(200);
         $unfilteredData = json_decode($unfilteredResponse->getContent());
-        $this->assertGreaterThan(0, $unfilteredData->count);
+        $this->assertGreaterThanOrEqual(0, $unfilteredData->count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php` around lines 1903 - 1936, In
testGetCurrentSummitSpeakersActivitiesCountFilteredBySelPlan update the initial
unfiltered count assertion to allow zero results by replacing the strict
assertGreaterThan(0) with assertGreaterThanOrEqual(0) so the test no longer
assumes there is at least one speaker activity; modify the assertion in this
test method where $unfilteredData->count is checked to use
assertGreaterThanOrEqual(0).

1874-1901: ⚡ Quick win

Consider using assertGreaterThanOrEqual(0) for count assertions.

Line 1900 uses assertGreaterThan(0) which requires the count to be strictly positive. This makes the test dependent on specific test data always having speaker activities. The equivalent submitters test (line 269 in OAuth2SummitSubmittersApiTest.php) uses assertGreaterThanOrEqual(0), which is more robust since a count of zero is a valid response for an activities count endpoint.

♻️ Recommended adjustment for consistency and robustness
         $this->assertNotNull($data);
         $this->assertTrue(isset($data->count));
-        $this->assertGreaterThan(0, $data->count);
+        $this->assertGreaterThanOrEqual(0, $data->count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php` around lines 1874 - 1901, The
test method testGetCurrentSummitSpeakersActivitiesCount currently asserts a
strictly positive count using $this->assertGreaterThan(0); change this to allow
zero by using $this->assertGreaterThanOrEqual(0) in the same method so the
speakers activities count can be zero without failing the test; update the
assertion call in testGetCurrentSummitSpeakersActivitiesCount accordingly for
consistency with the submitters test.

1938-1968: ⚡ Quick win

Consider using assertGreaterThanOrEqual(0) for count assertions.

Line 1967 uses assertGreaterThan(0), which is inconsistent with the submitters tests. A count of zero is a valid response when filtering yields no results, so assertGreaterThanOrEqual(0) would be more appropriate and consistent.

♻️ Recommended adjustment
         $this->assertNotNull($data);
         $this->assertTrue(isset($data->count));
-        $this->assertGreaterThan(0, $data->count);
+        $this->assertGreaterThanOrEqual(0, $data->count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php` around lines 1938 - 1968, In
testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentations replace the
strict positive assertion with a non-negative one: change the assertion on
$data->count (currently assertGreaterThan(0)) to assertGreaterThanOrEqual(0) so
the test accepts zero results when the filter yields none; update the assertion
call in that method accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php`:
- Around line 510-515: The controller currently casts the path parameter
$summit_id with intval, which converts any slug to 0 and breaks slug support;
update OAuth2SummitSubmittersApiController to stop unconditionally calling
intval($summit_id) — either pass $summit_id through unchanged to downstream
lookups or implement a conditional: if (is_numeric($summit_id)) cast to int and
use numeric lookup, otherwise treat it as a slug and call the slug-based lookup
routine; replace occurrences of intval($summit_id) (including the instances
around $summit_id at the top of the file and the later usage) so the endpoint
honors the documented “Summit ID or slug” contract.

In `@app/ModelSerializers/Summit/SummitSerializer.php`:
- Around line 323-330: The catch block currently logs only $ex->getMessage(),
losing stack and type; update the catch in the try around
$build_default_payment_gateway_profile_strategy->build(...) /
SerializerRegistry::getInstance()->getSerializer(...) so the full exception is
logged (for example pass the exception object to Log::warning or include it in
the context), e.g. replace Log::warning($ex->getMessage()) with a call that logs
the whole $ex so stack trace and exception type are preserved.

---

Duplicate comments:
In `@app/ModelSerializers/Summit/SummitSerializer.php`:
- Around line 335-342: The catch block around
$build_default_payment_gateway_profile_strategy->build in SummitSerializer::
(where you call SerializerRegistry::getInstance(), $serializer->serialize and
AbstractSerializer::filterExpandByPrefix) currently logs only $ex->getMessage();
change it to log the full exception (message plus stack trace) so you capture
context—e.g., pass the exception object or its full string/trace to Log::warning
instead of only getMessage(); follow the same pattern you used in the
registration profile handler to ensure consistency.

---

Nitpick comments:
In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php`:
- Around line 1903-1936: In
testGetCurrentSummitSpeakersActivitiesCountFilteredBySelPlan update the initial
unfiltered count assertion to allow zero results by replacing the strict
assertGreaterThan(0) with assertGreaterThanOrEqual(0) so the test no longer
assumes there is at least one speaker activity; modify the assertion in this
test method where $unfilteredData->count is checked to use
assertGreaterThanOrEqual(0).
- Around line 1874-1901: The test method
testGetCurrentSummitSpeakersActivitiesCount currently asserts a strictly
positive count using $this->assertGreaterThan(0); change this to allow zero by
using $this->assertGreaterThanOrEqual(0) in the same method so the speakers
activities count can be zero without failing the test; update the assertion call
in testGetCurrentSummitSpeakersActivitiesCount accordingly for consistency with
the submitters test.
- Around line 1938-1968: In
testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentations replace the
strict positive assertion with a non-negative one: change the assertion on
$data->count (currently assertGreaterThan(0)) to assertGreaterThanOrEqual(0) so
the test accepts zero results when the filter yields none; update the assertion
call in that method accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62fea433-b047-49d2-8cc2-668acf17a485

📥 Commits

Reviewing files that changed from the base of the PR and between 7cf7fae and 68235b1.

📒 Files selected for processing (17)
  • .env.example
  • .gitignore
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php
  • app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php
  • app/ModelSerializers/Summit/SummitSerializer.php
  • app/Models/Foundation/Main/Repositories/IMemberRepository.php
  • app/Models/Foundation/Summit/Repositories/ISpeakerRepository.php
  • app/Repositories/Summit/DoctrineMemberRepository.php
  • app/Repositories/Summit/DoctrineSpeakerRepository.php
  • database/seeders/ApiEndpointsSeeder.php
  • database/seeders/ConfigSeeder.php
  • docker-compose.override.testing.yml
  • routes/api_v1.php
  • tests/SubmitterRepositoryTest.php
  • tests/oauth2/OAuth2SummitSpeakersApiTest.php
  • tests/oauth2/OAuth2SummitSubmittersApiTest.php

Comment thread app/ModelSerializers/Summit/SummitSerializer.php Outdated
@mulldug mulldug force-pushed the fix/oauth2-exact-origin-matching branch from 68235b1 to f305115 Compare May 13, 2026 17:04
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-545/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens OAuth2 JS client origin validation by replacing substring matching with exact matching against normalized allowed origins.

Changes:

  • Parses the space-delimited allowed_origins token field into individual entries.
  • Trims entries and removes trailing slashes before comparison.
  • Uses strict in_array exact matching to prevent substring-origin bypasses.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if (
$token_info->getApplicationType() === 'JS_CLIENT'
&& (is_null($origin) || empty($origin)|| str_contains($token_info->getAllowedOrigins(), $origin) === false )
&& (is_null($origin) || empty($origin) || !in_array(rtrim($origin, '/'), $allowedOrigins, true))
Copy link
Copy Markdown
Collaborator

@smarcet smarcet May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mulldug
Asymmetric normalization — risk of false 403s for legitimate JS clients

$origin is built by RequestUtils::getOrigin(), which runs the header through URL\Normalizer (lowercases scheme/host, strips default ports :80/:443, canonicalizes percent-encoding, resolves dot segments, may add/strip trailing /).

$allowedOrigins here only gets trim() + rtrim('/') — no host-lowercasing, no port normalization, no percent-encoding canonicalization.

Combined with strict in_array(..., true) (byte-exact, case-sensitive), any IDP record stored as e.g. https://Example.com, https://example.com:443, or with percent-encoded chars will silently start returning 403 unauthorized_client after this ships — even though the old str_contains accidentally tolerated those mismatches.

Failure mode is silent and uniform across affected clients, and local tests won't catch it (fixtures use allowed_origins => '', so the JS_CLIENT branch is skipped).

Suggested fix: normalize each entry through the same URL\Normalizer before the strict compare, so both sides come out of the same pipeline:

$allowedOrigins = array_filter(array_map(function ($o) {
    $o = trim($o);
    if ($o === '') return '';
    try { $o = (new \URL\Normalizer($o))->normalize(); } catch (\Throwable $e) {}
    return rtrim($o, '/');
}, explode(' ', $token_info->getAllowedOrigins() ?? '')));

Recommended pre-rollout check: query the token store for allowed_origins entries containing uppercase letters, :80/:443, or %-encoding to surface clients at risk.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@smarcet
Copy link
Copy Markdown
Collaborator

smarcet commented May 13, 2026

@mulldug all proposed PR needs

  1. ref: <ticket_url> header
  2. the proper regresion tests that prove the bug:
    Add a unit test with a mocked $token_service returning a token whose allowed_origins = "https://example.com https://foo.bar" and assert (a) example is rejected, (b)
    https://example.com is accepted, (c) https://example.com/ is accepted, (d) empty Origin is rejected.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mulldug please review comments

);
throw new InvalidGrantTypeException(OAuth2Protocol::OAuth2Protocol_Error_InvalidToken);
}
$allowedOrigins = array_filter(array_map(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mulldug
Move the array build inside the JS_CLIENT branch so server-to-server tokens do not pay the cost

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Replace str_contains substring check with in_array exact match to prevent
crafted short origins from satisfying the allowed-origins guard on
JS_CLIENT tokens. Splits the space-delimited allowed_origins string from
the introspection response into individual entries before comparing.
@mulldug mulldug force-pushed the fix/oauth2-exact-origin-matching branch from f305115 to 1cc4102 Compare May 19, 2026 15:06
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-545/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php (1)

87-87: ⚡ Quick win

Consider adding a test for the second allowed origin.

The token stub includes https://foo.bar in the allowed origins string, but no test explicitly validates it. Adding a test case for this second origin would verify that space-delimited parsing correctly handles multiple origins.

➕ Suggested additional test

Add after line 153:

public function test_second_allowed_origin_is_accepted(): void
{
    $this->context->expects($this->once())->method('setAuthorizationContext');

    $response = $this->buildValidator('https://foo.bar')
        ->handle($this->buildRequest('https://foo.bar'), $this->next());

    $this->assertEquals(200, $response->getStatusCode());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php` at line 87, Add a
new unit test method named test_second_allowed_origin_is_accepted that mirrors
the existing origin test but uses 'https://foo.bar' to verify space-delimited
parsing in getAllowedOrigins; in the test call
buildValidator('https://foo.bar')->handle(buildRequest('https://foo.bar'),
$this->next()), expect
$this->context->expects($this->once())->method('setAuthorizationContext') and
assert the response status is 200 to confirm the second allowed origin is
accepted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php`:
- Line 87: Add a new unit test method named
test_second_allowed_origin_is_accepted that mirrors the existing origin test but
uses 'https://foo.bar' to verify space-delimited parsing in getAllowedOrigins;
in the test call
buildValidator('https://foo.bar')->handle(buildRequest('https://foo.bar'),
$this->next()), expect
$this->context->expects($this->once())->method('setAuthorizationContext') and
assert the response status is 200 to confirm the second allowed origin is
accepted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f51ea411-191b-4c11-8872-03ecbba4d46d

📥 Commits

Reviewing files that changed from the base of the PR and between 68235b1 and 1cc4102.

📒 Files selected for processing (3)
  • .gitignore
  • app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php
  • tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php
✅ Files skipped from review due to trivial changes (1)
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants