Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions src/sentry/identity/oauth2.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import logging
import re
import secrets
from time import time
from typing import Any
Expand Down Expand Up @@ -34,6 +35,7 @@
from sentry.shared_integrations.exceptions import ApiError, ApiInvalidRequestError, ApiUnauthorized
from sentry.users.models.identity import Identity
from sentry.utils.http import absolute_uri
from sentry.utils.strings import to_single_line_str, truncatechars

from .base import Provider

Expand All @@ -43,6 +45,23 @@
ERR_INVALID_STATE = "An error occurred while validating your request."
ERR_TOKEN_RETRIEVAL = "Failed to retrieve token from the upstream service."

_ALLOWED_ERROR_CHARACTERS = re.compile(r"[^\w\s.,:/@-]")
_MAX_ERROR_LENGTH = 256


def _sanitize_oauth_error_parameter(error: str) -> str:
"""
Normalize user supplied OAuth error strings before logging or displaying them.

We collapse whitespace to a single line, strip any characters outside a conservative
allow list, and truncate excessively long values to keep log lines stable.
"""

condensed = to_single_line_str(error)
cleaned = _ALLOWED_ERROR_CHARACTERS.sub("", condensed)
trimmed = truncatechars(cleaned, _MAX_ERROR_LENGTH).strip()
return trimmed or "[filtered]"


def _redirect_url(pipeline: IdentityPipeline) -> str:
associate_url = reverse(
Expand Down Expand Up @@ -354,16 +373,17 @@ def dispatch(self, request: HttpRequest, pipeline: IdentityPipeline) -> HttpResp
with record_event(
IntegrationPipelineViewType.OAUTH_CALLBACK, pipeline.provider.key
).capture() as lifecycle:
error = request.GET.get("error")
raw_error = request.GET.get("error")
state = request.GET.get("state")
code = request.GET.get("code")

if error:
if raw_error:
sanitized_error = _sanitize_oauth_error_parameter(raw_error)
lifecycle.record_failure(
IntegrationPipelineErrorReason.TOKEN_EXCHANGE_MISMATCHED_STATE,
extra={"error": error},
extra={"error": sanitized_error},
)
return pipeline.error(f"{ERR_INVALID_STATE}\nError: {error}")
return pipeline.error(f"{ERR_INVALID_STATE}\nError: {sanitized_error}")

if state != pipeline.fetch_state("state"):
extra = {
Expand Down
31 changes: 31 additions & 0 deletions tests/sentry/identity/test_oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from sentry.identity.pipeline import IdentityPipeline
from sentry.identity.providers.dummy import DummyProvider
from sentry.integrations.types import EventLifecycleOutcome
from sentry.integrations.utils.metrics import EventLifecycle
from sentry.shared_integrations.exceptions import ApiUnauthorized
from sentry.testutils.asserts import assert_failure_metric, assert_slo_metric
from sentry.testutils.silo import control_silo_test
Expand Down Expand Up @@ -157,6 +158,36 @@ def test_api_error(self, mock_record: MagicMock) -> None:

assert_failure_metric(mock_record, ApiUnauthorized('{"token": "a-fake-token"}'))

def test_error_parameter_is_sanitized(self, mock_record: MagicMock) -> None:
malicious_error = (
"${${:::::::::::::::::-j}ndi:dns${:::::::::::::::::-:}${::-/}${::-/}"
"dns.log4j.009365.102962-13420.102962.9d0bf${::-.}1${::-.}bxss.me}}\nnext-line"
)
request = RequestFactory().get("/", {"error": malicious_error})
request.subdomain = None

pipeline = MagicMock()
pipeline.provider.key = "dummy"
sentinel_response = object()
pipeline.error.return_value = sentinel_response

with patch.object(
EventLifecycle, "record_failure", wraps=EventLifecycle.record_failure
) as mock_failure:
response = self.view.dispatch(request, pipeline)

assert response is sentinel_response
assert pipeline.error.called

recorded_extra = mock_failure.call_args.kwargs["extra"]
sanitized_error = recorded_extra["error"]

assert sanitized_error != malicious_error
assert "${" not in sanitized_error
assert "\n" not in sanitized_error
assert sanitized_error
assert sanitized_error in pipeline.error.call_args[0][0]


@control_silo_test
class OAuth2LoginViewTest(TestCase):
Expand Down
Loading