Skip to content

fix(security): stop leaking OAuth provider exception messages (#323)#335

Open
dmytrocraft wants to merge 4 commits into
mainfrom
security/323-oauth-exception-message-leak
Open

fix(security): stop leaking OAuth provider exception messages (#323)#335
dmytrocraft wants to merge 4 commits into
mainfrom
security/323-oauth-exception-message-leak

Conversation

@dmytrocraft

@dmytrocraft dmytrocraft commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Security fix — Closes #323

Vulnerability (CWE-209, MEDIUM)

OAuthExceptionListener returned the client-facing application/problem+json response with detail => $exception->getMessage(). For OAuthProviderException that message is OAuth provider <p> error: <raw>, where <raw> is the verbatim getMessage() of the Guzzle/league exception caught in GitHubOAuthProvider during token exchange / profile fetch. Those upstream messages routinely embed the outbound request URL (carrying client_id), the provider's raw error body, HTTP status lines, and internal hostnames.

The listener is not debug-gated, and /api/auth/social is PUBLIC_ACCESS in security.yaml. An unauthenticated attacker who forces the provider exchange to fail (crafted/invalid code) receives a 503 whose detail discloses the configured client_id, the exact provider endpoints, internal error semantics, and internal hostnames — useful for reconnaissance.

Fix

  • Add a static, provider-agnostic detail string to every entry in ERROR_CODE_MAP and emit that instead of the raw exception message. Response shape, HTTP status codes, error_code, type, title, and Content-Type are unchanged (no client-contract break).
  • Inject Psr\Log\LoggerInterface (autowired via existing _defaults) and log the full exception message + object at error level with the error_code, preserving server-side diagnosability.
  • Remediation is enforced at the single client-facing boundary (the listener), so it also covers any future provider adapter. Hexagonal/DDD boundaries preserved; no new directories/threshold changes.

Tests

tests/Unit/OAuth/Application/EventListener/OAuthExceptionListenerTest.php

  • Negative: a realistic raw Guzzle message (with client_id, secret, provider URL, internal hostname, 401) is asserted absent from the response detail — fails before the fix, passes after.
  • Edge: every handled exception type is verified to never echo its raw message; ignored exceptions are neither logged nor responded to.
  • Positive: full message is logged with error_code + exception context; existing status/contract assertions still pass.

Local verification (one-off containers)

  • phpunit (filtered OAuthException|ExceptionListener): OK (20 tests, 92 assertions)
  • phpunit (full Unit suite): OK (2262 tests, 6252 assertions)
  • deptrac: Violations 0
  • psalm: No errors found
  • php-cs-fixer: 0 of 2 files need fixing

BMAD spec

specs/security-323-oauth-exception-message-leak/ (prd.md, stories.md)

🤖 Generated with Claude Code


Summary by cubic

Stop leaking raw OAuth provider error messages to clients by returning static, safe detail strings in the problem+json response. The response shape and status codes stay the same; full errors are logged server-side for diagnosis.

  • Bug Fixes

    • Use static, provider-agnostic detail strings from OAuthExceptionListener::ERROR_CODE_MAP instead of $exception->getMessage().
    • Inject Psr\Log\LoggerInterface and log the full exception (with error_code and context) at error level, prefixed with OAuth flow failed: .
    • Keep unmapped exceptions ignored by this listener.
    • Add unit tests to confirm no message leak, verify logging (including exact log prefix), and preserve the client contract.
  • Refactors

    • Extract buildProblemResponse() for clearer response construction and shorter listener method; introduce LOG_MESSAGE_PREFIX constant.
    • Tidy tests to satisfy PHP Insights and Infection, restoring MSI to 100% (no functional changes).
    • Update security story verification commands to portable make unit-tests and docker compose exec variants.

Written for commit 9ab8c28. Summary will update on new commits.

Review in cubic

OAuthExceptionListener built the client problem+json response with
`detail => $exception->getMessage()`. For OAuthProviderException that
message is `OAuth provider <p> error: <raw>` where <raw> is the verbatim
getMessage() of the Guzzle/league exception caught in GitHubOAuthProvider
during token exchange / profile fetch. Those messages routinely embed the
outbound request URL (carrying client_id), provider error bodies, HTTP
status lines, and internal hostnames. The listener is not debug-gated and
`/api/auth/social` is PUBLIC_ACCESS, so an unauthenticated attacker who
forces the provider exchange to fail learns the client_id, provider
endpoints, and internal hostnames (CWE-209, information exposure).

Fix:
- Give every entry in ERROR_CODE_MAP a static, provider-agnostic `detail`
  string and emit that instead of the raw exception message. The response
  shape, status codes, error_code, and Content-Type are unchanged.
- Inject Psr\Log\LoggerInterface (autowired) and log the full exception
  message + object server-side at error level, preserving diagnosability.

Tests (tests/Unit/.../OAuthExceptionListenerTest.php):
- Negative: a realistic raw Guzzle message with client_id, secret, provider
  URL, internal hostname, and 401 is asserted absent from the response
  detail (fails before the fix, passes after).
- Edge: every handled exception type is checked to never echo its raw
  message; ignored exceptions are neither logged nor responded to.
- Positive: full message is logged with error_code + exception context.

Local verification (one-off containers):
- phpunit (filtered): OK (20 tests, 92 assertions)
- phpunit (full Unit): OK (2262 tests, 6252 assertions)
- deptrac: Violations 0
- psalm: No errors found
- php-cs-fixer: 0 of 2 files

Closes #323

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@dmytrocraft, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 13 minutes and 19 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 03fff73d-588a-4419-9da1-448155f32022

📥 Commits

Reviewing files that changed from the base of the PR and between fe53ccb and 9ab8c28.

📒 Files selected for processing (4)
  • specs/security-323-oauth-exception-message-leak/prd.md
  • specs/security-323-oauth-exception-message-leak/stories.md
  • src/OAuth/Application/EventListener/OAuthExceptionListener.php
  • tests/Unit/OAuth/Application/EventListener/OAuthExceptionListenerTest.php
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/323-oauth-exception-message-leak

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 4 files

Confidence score: 2/5

  • There is a concrete security risk in src/OAuth/Application/EventListener/OAuthExceptionListener.php: logging raw upstream OAuth exception messages can leak sensitive values like client_secret into persistent logs.
  • Because this issue is high severity (7/10) with high confidence (9/10) and user/security impact, merge risk is elevated rather than a routine minor-fix PR.
  • The issue in specs/security-323-oauth-exception-message-leak/stories.md appears lower impact and mostly affects reproducibility of verification steps, not runtime behavior.
  • Pay close attention to src/OAuth/Application/EventListener/OAuthExceptionListener.php, specs/security-323-oauth-exception-message-leak/stories.md - prevent secret leakage in logs and make verification instructions environment-agnostic.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/OAuth/Application/EventListener/OAuthExceptionListener.php">

<violation number="1" location="src/OAuth/Application/EventListener/OAuthExceptionListener.php:91">
P1: Raw upstream OAuth exception messages are logged verbatim, which can persist secrets (e.g., `client_secret`) into logs.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as External Client (unauthenticated)
    participant Kernel as Symfony HTTP Kernel
    participant Listener as OAuthExceptionListener
    participant Logger as PSR-3 Logger
    participant Provider as GitHubOAuthProvider

    Note over Client,Provider: Social Callback Flow with Error Handling

    Client->>Kernel: GET /api/auth/social?code=invalid
    Kernel->>Provider: exchangeCode(code)
    Provider->>Provider: Guzzle POST to github.com/...?client_id=xxx
    Provider-->>Kernel: OAuthProviderException("Client error: POST ... 401 ...")
    Kernel->>Listener: __invoke(ExceptionEvent)

    alt Handled Exception Type
        Listener->>Listener: Lookup exception class in ERROR_CODE_MAP
        Note over Listener: NEW: Uses static 'detail' from map instead of exception message
        Listener->>Logger: error("OAuth flow failed: ...", error_code, exception)
        Logger-->>Listener: Log written
        Listener->>Kernel: Set JsonResponse (status, error_code, static detail)
        Kernel-->>Client: 503 application/problem+json {"detail":"The authentication provider is currently unavailable..."}
    else Unmapped Exception Type
        Listener->>Listener: Class not in ERROR_CODE_MAP → return early
        Listener-->>Kernel: No response set, no log written
        Kernel-->>Client: Default Symfony error response
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +91 to +94
$this->logger->error('OAuth flow failed: ' . $exception->getMessage(), [
'error_code' => $mapping['error_code'],
'exception' => $exception,
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Raw upstream OAuth exception messages are logged verbatim, which can persist secrets (e.g., client_secret) into logs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/OAuth/Application/EventListener/OAuthExceptionListener.php, line 91:

<comment>Raw upstream OAuth exception messages are logged verbatim, which can persist secrets (e.g., `client_secret`) into logs.</comment>

<file context>
@@ -65,13 +88,18 @@ public function __invoke(ExceptionEvent $event): void
             return;
         }
 
+        $this->logger->error('OAuth flow failed: ' . $exception->getMessage(), [
+            'error_code' => $mapping['error_code'],
+            'exception' => $exception,
</file context>
Suggested change
$this->logger->error('OAuth flow failed: ' . $exception->getMessage(), [
'error_code' => $mapping['error_code'],
'exception' => $exception,
]);
$this->logger->error('OAuth flow failed.', [
'error_code' => $mapping['error_code'],
'exception_class' => $exception::class,
]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged, but this is a deliberate, documented, test-enforced design rather than a leak. The security boundary for CWE-209 is the client response, where the raw message is fully suppressed (every 'detail' is a static, provider-agnostic string — see testProviderExceptionDoesNotLeakRawUpstreamMessage / testHandledExceptionsNeverLeakTheirRawMessage). Server-side logs are a trusted operator boundary; FR-3/FR-4 (Story 2) intentionally record the full message + exception for diagnosability, asserted by testFullExceptionMessageIsLoggedServerSide and testLoggedMessagePrefixesTheRawExceptionMessage. The specific concern (client_secret) does not hold in practice: the secret travels in the token-exchange request body, while league/oauth2-client's IdentityProviderException message is built from the provider response (error/error_description), not the request. Removing the server-side message would break the documented behavior and reduce diagnosability for a threat that is not realized here. No code change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Got it, thanks for the detailed clarification.

Comment thread specs/security-323-oauth-exception-message-leak/stories.md Outdated
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fe53ccb) to head (9ab8c28).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #335   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          551       551           
  Lines         9541      9547    +6     
=========================================
+ Hits          9541      9547    +6     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Signed-off-by: Vadym <kostiukdsfv@gmail.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

Refactor OAuthExceptionListener to satisfy PHP Insights (extract
response builder to cut function length, single-line detail to clear
line-length, named log prefix constant) and add a unit test that pins
the exact 'OAuth flow failed: ' + message log contract to kill the two
escaped Infection mutants (concat-swap and prefix-removal), restoring
MSI to 100%. Tidy the leak test to satisfy the tests phpinsights config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

Replace machine-specific verification commands in the security story
with portable, repository-standard equivalents (make unit-tests and a
docker compose exec variant), removing hardcoded host paths and a
branch-specific image tag so the steps are reproducible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmytrocraft

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Re-trigger cubic

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.

[Security] medium: Raw OAuth provider exception messages leaked to unauthenticated clients

2 participants