-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(oauth): Add strict redirect URI validation mode #99003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
f1cf49c
feat(oauth): Add strict redirect URI validation mode
dcramer 47a3cc9
Stick to spec
dcramer 03b8681
Swap to versioning field
dcramer 5edc526
Fix lockfile
dcramer 1f4b506
Merge branch 'master' into oauth-redirect-uri-exact
dcramer cf60c19
rebase migration
dcramer a870984
support https on loopback
dcramer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| from django.db import migrations | ||
|
|
||
| import sentry.db.models.fields.bounded | ||
| from sentry.new_migrations.migrations import CheckedMigration | ||
|
|
||
|
|
||
| class Migration(CheckedMigration): | ||
| # Introduce ApiApplication.version; default for new rows set to 0 (legacy) | ||
| is_post_deployment = False | ||
|
|
||
| dependencies = [ | ||
| ("sentry", "0978_break_commit_fks"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="apiapplication", | ||
| name="version", | ||
| field=sentry.db.models.fields.bounded.BoundedPositiveIntegerField( | ||
| default=0, db_index=True, db_default=0 | ||
| ), | ||
| ), | ||
| # Keep default for new rows as 0 (legacy). Later we will bump default to 1 when ready. | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,11 @@ | ||
| import logging | ||
| import os | ||
| import secrets | ||
| from enum import Enum | ||
| from typing import Any, ClassVar, Literal, Self, TypeIs | ||
| from urllib.parse import urlparse, urlunparse | ||
|
|
||
| import petname | ||
| import sentry_sdk | ||
| from django.contrib.postgres.fields.array import ArrayField | ||
| from django.db import models, router, transaction | ||
| from django.utils import timezone | ||
|
|
@@ -25,6 +26,19 @@ | |
| from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope | ||
| from sentry.types.region import find_all_region_names | ||
|
|
||
| logger = logging.getLogger("sentry.oauth") | ||
|
|
||
|
|
||
| # Feature flags for ApiApplication behavior, version-gated. | ||
| class ApiApplicationFeature(str, Enum): | ||
| STRICT_REDIRECT_URI = "strict-redirect-uri" | ||
|
|
||
|
|
||
| # Map feature → minimum version that enables it. | ||
| FEATURE_MIN_VERSION: dict[ApiApplicationFeature, int] = { | ||
| ApiApplicationFeature.STRICT_REDIRECT_URI: 1, | ||
| } | ||
|
|
||
|
|
||
| def generate_name(): | ||
| return petname.generate(2, " ", letters=10).title() | ||
|
|
@@ -71,6 +85,22 @@ class ApiApplication(Model): | |
| # ApiApplication by default provides user level access | ||
| # This field is true if a certain application is limited to access only a specific org | ||
| requires_org_level_access = models.BooleanField(default=False, db_default=False) | ||
| # Application version for feature-gating behavioral changes. | ||
| # Existing apps are version 0 ("legacy"); new apps default to 0 until all | ||
| # breaking changes are ready, then the default will be bumped to 1 | ||
| # ("oauth-21-draft"). | ||
| # TODO(dcramer): When all breaking features are shipped, bump both | ||
| # default and db_default to 1 and add a migration to update the field | ||
| # defaults accordingly. | ||
| version = BoundedPositiveIntegerField( | ||
| default=0, | ||
| db_index=True, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will we be querying on |
||
| choices=( | ||
| (0, _("legacy")), | ||
| (1, _("oauth-21-draft")), | ||
| ), | ||
| db_default=0, | ||
| ) | ||
|
|
||
| objects: ClassVar[BaseManager[Self]] = BaseManager(cache_fields=("client_id",)) | ||
|
|
||
|
|
@@ -108,6 +138,12 @@ def is_active(self): | |
| def is_allowed_response_type(self, value: object) -> TypeIs[Literal["code", "token"]]: | ||
| return value in ("code", "token") | ||
|
|
||
| def has_feature(self, feature: ApiApplicationFeature) -> bool: | ||
| min_version = FEATURE_MIN_VERSION.get(feature) | ||
| if min_version is None: | ||
| return False | ||
| return self.version >= min_version | ||
|
|
||
| def normalize_url(self, value): | ||
| parts = urlparse(value) | ||
| normalized_path = os.path.normpath(parts.path) | ||
|
|
@@ -118,25 +154,62 @@ def normalize_url(self, value): | |
| return urlunparse(parts._replace(path=normalized_path)) | ||
|
|
||
| def is_valid_redirect_uri(self, value): | ||
| # Spec references: | ||
| # - Exact match to one of the registered redirect URIs (RFC 6749 §3.1.2.3): | ||
| # https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 | ||
| # - Native apps loopback exception (RFC 8252 §8.4): | ||
| # https://datatracker.ietf.org/doc/html/rfc8252#section-8.4 | ||
| value = self.normalize_url(value) | ||
|
|
||
| for redirect_uri in self.redirect_uris.split("\n"): | ||
| ruri = self.normalize_url(redirect_uri) | ||
| # First: exact match only (spec-compliant), no logging. | ||
| normalized_ruris = [ | ||
| self.normalize_url(redirect_uri) for redirect_uri in self.redirect_uris.split("\n") | ||
| ] | ||
| for ruri in normalized_ruris: | ||
| if value == ruri: | ||
| return True | ||
| if value.startswith(ruri): | ||
| with sentry_sdk.isolation_scope() as scope: | ||
| scope.set_context( | ||
| "api_application", | ||
| { | ||
|
|
||
| # RFC 8252 §8.4 / §7: For loopback interface redirects in native apps, accept | ||
| # any ephemeral port when the registered URI omits a port. Match scheme, host, | ||
| # path (and query) exactly, ignoring only the port. | ||
| try: | ||
| v_parts = urlparse(value) | ||
| except Exception: | ||
| v_parts = None | ||
| if ( | ||
| v_parts | ||
| and v_parts.scheme in {"http", "https"} | ||
| and v_parts.hostname in {"127.0.0.1", "localhost", "::1"} | ||
| ): | ||
| for ruri in normalized_ruris: | ||
| try: | ||
| r_parts = urlparse(ruri) | ||
| except Exception: | ||
| continue | ||
| if ( | ||
| r_parts.scheme in {"http", "https"} | ||
| and r_parts.hostname in {"127.0.0.1", "localhost", "::1"} | ||
| and r_parts.port is None # registered without a fixed port | ||
| and v_parts.scheme == r_parts.scheme | ||
| and v_parts.hostname == r_parts.hostname | ||
| and v_parts.path == r_parts.path | ||
| and v_parts.query == r_parts.query | ||
| ): | ||
| return True | ||
|
|
||
| # Then: prefix-only match (legacy behavior). Log on success. | ||
| if not self.has_feature(ApiApplicationFeature.STRICT_REDIRECT_URI): | ||
| for ruri in normalized_ruris: | ||
| if value.startswith(ruri): | ||
| logger.warning( | ||
| "oauth.prefix_matched_redirect_uri", | ||
| extra={ | ||
| "client_id": self.client_id, | ||
| "redirect_uri": value, | ||
| "allowed_redirect_uris": self.redirect_uris, | ||
| "matched_prefix": ruri, | ||
| }, | ||
| ) | ||
| message = "oauth.prefix-matched-redirect-uri" | ||
| sentry_sdk.capture_message(message, level="info") | ||
| return True | ||
| return True | ||
| return False | ||
|
|
||
| def get_default_redirect_uri(self): | ||
|
|
@@ -159,6 +232,7 @@ def get_audit_log_data(self): | |
| "redirect_uris": self.redirect_uris, | ||
| "allowed_origins": self.allowed_origins, | ||
| "status": self.status, | ||
| "version": self.version, | ||
| } | ||
|
|
||
| @classmethod | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need to set both
defaultanddb_default. I'd just go withdb_defaulthere.