
Sentry Security
- 88 installs
- 44.5k repo stars
- Updated August 5, 2026
- getsentry/sentry
sentry-security is an agent skill that Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger key.
About
Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check for IDOR", "access control review", "org scoping", "cross-org", "security audit endpoint". --- name: sentry-security description: 'Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check for IDOR", "access control review", "org scoping", "cross-org", "security audit endpoint".' allowed-tools: Read Grep Glob Bash --- # Sentry Security Review Find security vulnerabilities in Sentry code by checking for the patterns that have caused real vulnerabilities in this codebase. It encodes patterns from 37 real security patches shipped in the last year - not generic OWASP theory. ## Scope Review the code provided by the user (file, diff, or endpoint). Research the codebase as needed to build confidence before reporting.
- Sentry Security Review
- Where does the ID enter? (query param, request body, URL kwarg)
- Where is it used in an ORM query?
- Between (1) and (2), is the query scoped by organization_id or project_id
- `Model.objects.get(id=request.data["something_id"])` - no org scope
Sentry Security by the numbers
- 88 all-time installs (skills.sh)
- Ranked #1,163 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
sentry-security capabilities & compatibility
- Capabilities
- sentry security review · where does the id enter? (query param, request b · where is it used in an orm query? · between (1) and (2), is the query scoped by orga · `model.objects.get(id=request.data["something_id
- Use cases
- documentation
What sentry-security says it does
--- name: sentry-security description: 'Sentry-specific security review based on real vulnerability history.
Use when reviewing Sentry endpoints, serializers, or views for security issues.
It encodes patterns from 37 real security patches shipped in the last year — not generic OWASP theory.
## Scope Review the code provided by the user (file, diff, or endpoint).
npx skills add https://github.com/getsentry/sentry --skill sentry-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 88 |
|---|---|
| repo stars | ★ 44.5k |
| Last updated | August 5, 2026 |
| Repository | getsentry/sentry ↗ |
What problem does sentry-security solve for developers using this skill?
Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check
Who is it for?
Developers who need sentry-security patterns described in the cached skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check
What you get
Actionable workflows and conventions from SKILL.md for sentry-security.
Files
Sentry Security Review
Find security vulnerabilities in Sentry code by checking for the patterns that have caused real vulnerabilities in this codebase.
This skill is Sentry-specific. It encodes patterns from 37 real security patches shipped in the last year — not generic OWASP theory.
Scope
Review the code provided by the user (file, diff, or endpoint). Research the codebase as needed to build confidence before reporting.
Report only HIGH and MEDIUM confidence findings. Do not report theoretical issues.
| Confidence | Criteria | Action |
|---|---|---|
| HIGH | Traced the flow, confirmed no check exists | Report with fix |
| MEDIUM | Check may exist but could not confirm | Report as needs verification |
| LOW | Theoretical or mitigated elsewhere | Do not report |
Step 1: Classify the Code
Determine what you're reviewing and load the relevant reference.
| Code Type | Load Reference |
|---|---|
API endpoint (inherits from *Endpoint) | references/endpoint-patterns.md |
| Serializer or form field | references/serializer-patterns.md |
| Email template or HTML rendering | references/output-sanitization.md |
| Token, OAuth, or session handling | references/token-lifecycle.md |
| Role or permission logic | references/privilege-escalation.md |
If the code spans multiple categories, load all relevant references.
Always load references/enforcement-layers.md — it documents where security checks can legitimately live in Sentry's request lifecycle. A check in any layer counts as enforcement.
Step 2: Check for the Top 6 Vulnerability Classes
These are ordered by frequency from the last year of real patches.
Check 1: Cross-Org Object Access (IDOR) — 9 patches last year
The most common vulnerability. An endpoint accepts an ID from the request but does not scope the query by the organization from the URL.
Trace this flow for every ID that comes from the request:
1. Where does the ID enter? (query param, request body, URL kwarg)
2. Where is it used in an ORM query?
3. Between (1) and (2), is the query scoped by organization_id or project_id
from the URL (NOT from the request body)?Red flags:
Model.objects.get(id=request.data["something_id"])— no org scopeModel.objects.filter(id=request.GET["id"])— no org scopeproject_idfrom request body/query used directly withoutProject.objects.filter(id=pid, organization_id=organization.id)- Endpoint inherits
OrganizationEndpointbut handler method does not accept or use theorganizationparameter
Safe patterns:
- Query includes
organization_id=organization.idwhereorganizationcomes fromconvert_args() - Uses
self.get_projects()which scopes by org internally - Object is fetched via URL kwargs resolved by
convert_args() - Unscoped query is a guard that only raises an error (never returns data), AND
a downstream query in the same flow IS org-scoped and raises the same error — no differential behavior means no information leak
Check 2: Missing Authorization Checks — 10 patches last year
An endpoint or serializer performs a sensitive operation without verifying the user has permission.
Check:
- Does the endpoint inherit from the right base class? (
OrganizationEndpoint,ProjectEndpoint, etc.) - Does it declare
permission_classes? If not, it inherits the base class default — verify that's appropriate. - For serializer fields that reference other objects: do they validate the user can access those objects?
- For Django views (not DRF): is there a
@login_requiredor equivalent?
Check 3: Privilege Escalation / Role Abuse — 3 patches last year
A user can assign ownership, modify roles, or escalate access beyond what their role allows.
Check:
- Owner/assignee fields: uses
OwnerActorField(validates membership), NOTActorField(allows any actor) - Role modification endpoints: verify the requesting user's role is >= the target role
- Team assignment: verify the user is a member of the target team (or has
team:admin)
Check 4: Token / Session Security — 5 patches last year
Token lifecycle gaps that allow unauthorized access.
Check:
- Token refresh: is the application's active status checked before granting a refresh?
- Org-level tokens: is
organization_idrequired and validated? - Member status: is the member's enabled/disabled status checked before granting tokens?
- Impersonation: are impersonated sessions rate-limited?
Check 5: Output Sanitization (XSS/HTML Injection) — 4 patches last year
User-controlled strings rendered unsafely in emails, markdown, or HTML.
Check:
- User display names, team names, org names used in email templates: are they sanitized?
- Markdown rendering: is custom CSS or HTML allowed through?
format_html()vs string concatenation in templatesmark_safe()called on user input
Check 6: Auth/MFA Gaps — 3 patches last year
Authentication state inconsistencies.
Check:
- When removing an authenticator: are recovery codes cleaned up?
- CSRF token handling: is it synced across tabs/windows?
- Session invalidation: does removing auth factors properly invalidate sessions?
If no checks produced a potential finding, stop and report zero findings. Do not invent issues to fill the report. An empty result is the correct output when the code has no vulnerabilities matching these patterns.
Step 3: Trace the Full Enforcement Chain
For each potential finding, trace the complete request flow end-to-end. Do not stop at the authentication class — follow into the endpoint handler, then into any business logic classes it delegates to (e.g., Validator, Refresher, GrantExchanger).
1. Authentication class → does authenticate() or authenticate_token() enforce the check?
2. Permission class → does has_permission() enforce it?
3. convert_args() → does has_object_permission() / determine_access() enforce it?
4. Access module → does from_rpc_auth() or from_request() enforce it?
5. Handler method → does the endpoint handler enforce it?
6. Business logic classes → do downstream classes (Validator, etc.) enforce it?
7. Serializer → do validate_*() methods enforce it?A check at ANY layer is enforcement. Before marking HIGH, confirm the check is absent from all layers using the checklist in enforcement-layers.md.
If you cannot confirm the check is absent from every layer, mark the finding as MEDIUM (needs verification), not HIGH.
Cross-flow enforcement for token issuance: For token/credential issuance flows, also check whether the issued credential is blocked at usage time (e.g., determine_access() rejects it at all endpoints in the relevant scope). Classify based on the enforcement scope:
- Centralized enforcement (check runs in a permission class inherited by all endpoints in the affected scope) → the credential is effectively inert → LOW (do not report)
- Scattered enforcement (only some endpoints or serializers check, others may not) → MEDIUM (report as needs verification)
See enforcement-layers.md "Cross-Flow Enforcement."
Non-DRF views: OAuth views are plain Django views — the 7-layer DRF model does not apply to the view itself. Check the view's own decorators and handler logic. But tokens issued by these views are later used at DRF endpoints where the full enforcement chain applies.
Step 4: Report Findings
````markdown
Sentry Security Review: [Component]
Findings
[SENTRY-001] [Title] (Severity: Critical/High/Medium)
- Category: [IDOR | Missing Auth | Privilege Escalation | Token | XSS | Auth/MFA]
- Location:
path/to/file.py:123 - Confidence: HIGH — confirmed through code tracing
- Issue: [What the vulnerability is]
- Trace:
1. [Step-by-step trace showing how the vulnerability is reached]
- Impact: [What an attacker could do]
- Fix:
[Code that fixes the issue — must enforce, not document]````
- Precedent: [Similar past fix if applicable, e.g. "Similar to #104990 PromptsActivity IDOR"]
Needs Verification
[MEDIUM confidence items with explanation of what to verify]
Not Reviewed
[Areas outside the scope of this review]
Fix suggestions must include actual enforcement code. Never suggest a comment or docstring as a fix.sentry-security
Sentry-specific security review skill synthesized from real vulnerability history.
Source Commits
This skill was synthesized by analyzing 37 security patches on master from 2025-02-18 to 2026-02-18. The patterns, examples, and checklists in the skill are derived directly from these fixes.
IDOR / Cross-Org Data Access (9)
| SHA | Date | Description |
|---|---|---|
893c7a939f53 | 2026-02-12 | Prevent cross-org condition injection via conditionGroupId IDOR (#108156) |
32114eed29f8 | 2026-02-10 | Fix IDOR vulnerability in group operations via qualified short ID |
65ff1a9dc0fa | 2026-01-15 | fix(security): IDOR in PromptsActivityEndpoint GET - scope project by organization (#104990) |
179323a012b3 | 2026-01-08 | fix(security): IDOR in OrganizationOnDemandRuleStatsEndpoint - scope Project by organization (#104988) |
f32888f2490e | 2026-01-06 | fix(security): IDOR in OrganizationEventsEndpoint - scope DashboardWidget by organization (#104987) |
8aff7c4bc575 | 2026-01-06 | fix(security): IDOR in OrganizationEventsStatsEndpoint - scope DashboardWidget by organization (#104986) |
58b5a8a1a1e6 | 2025-12-30 | Validate action filter organization ownership to prevent cross-org injection (#105533) |
b43b12ae9b1f | 2025-12-16 | fix(security): IDOR in OrganizationDeriveCodeMappingsEndpoint - scope Project by organization (#104980) |
5dfd66d27c04 | 2025-12-15 | fix: Correct missing organization constraint in PromptsActivityEndpoint (#104920) |
Missing Authorization / Access Checks (10)
| SHA | Date | Description |
|---|---|---|
d714026543ec | 2026-02-17 | fix(teams): Prevent contributors from downgrading org admins' team roles (#108288) |
4f50b4dfb588 | 2026-01-27 | (fix): add auth check to ProjectOwnershipRequestSerializer (#107064) |
1ccb2c745e61 | 2026-01-27 | Check default org membership before changing superuser/staff privilege (#106877) |
0c3841dfac16 | 2026-01-23 | add auth checks in detector workflow (#106815) |
89ab908aed98 | 2026-01-20 | Add project check to bundle assembly (#106571) |
7be714a12f39 | 2026-01-20 | feat(admin): Restrict /manage/ endpoint to non-SaaS modes (#106530) |
45bc78fd5751 | 2026-01-02 | Add functional org filter to GroupEventJsonView (#105601) |
fd7c6b1b8b94 | 2025-12-17 | fix(replays): restrict to active staff instead of superuser with user-based replay permissions (#105140) |
7049b522d84c | 2025-09-15 | fix(coding-agents): set organization event permission on endpoint (#99515) |
6ace85cf45d3 | 2025-07-22 | fix(security): Simplify permissions check for notification actions (#95612) |
Privilege Escalation / Role Abuse (3)
| SHA | Date | Description |
|---|---|---|
86fa75c2b7e5 | 2025-08-26 | fix(member-team-details): prevent role downgrade by low-privilege users (#98213) |
fba35737f88d | 2026-01-15 | Fix validators using ActorField, replace with OwnerActorField (#106074) |
b6526b6333d2 | 2026-01-28 | Update OwnerActorField usage, refactor RuleSerializer, OpenAPI serializers (#106984) |
Token / Session Security (5)
| SHA | Date | Description |
|---|---|---|
4a95d060eac6 | 2026-01-28 | Rate limit API requests if it's an impersonated session (#106814) |
8f2542c70d01 | 2025-12-19 | fix(sentry-apps): Prevent inactive applications from refreshing tokens (#105269) |
6bfd39e82129 | 2025-12-16 | fix(oauth): Require organization_id for org-level access applications (#105064) |
e14e33ebdcaa | 2025-09-17 | fix(security): deny actions over org auth tokens by personal token (#99457) |
461388ea4542 | 2025-06-02 | fix(security): do not allow auth user token requests if member is disabled (#92616) |
XSS / Injection / Output Sanitization (4)
| SHA | Date | Description |
|---|---|---|
f7d362576663 | 2026-02-12 | fix(mail): Sanitize user display names in invite and integration request emails (#108165) |
849bff88fd8d | 2026-02-12 | fix(mail): Sanitize user display names in team access request emails (#108154) |
6c308dc7f9b2 | 2026-01-22 | fix: disallow custom CSS in marked (#106368) |
ea60b818985 | 2025-07-22 | fix(oauth): Add state validation to prevent promo code conflicts (#95742) |
Authentication / MFA (3)
| SHA | Date | Description |
|---|---|---|
86483e5aee50 | 2026-02-17 | fix(security): Delete recovery codes when last primary authenticator is removed (#108264) |
1310f27ecc5f | 2026-02-03 | fix(auth): Sync CSRF token on form submit for multi-tab scenarios (#107389) |
97593dcac7ec | 2026-01-30 | fix(auth): Fix CSRF token refresh for multi-tab auth scenarios (#107214) |
Misc Security Hardening (3)
| SHA | Date | Description |
|---|---|---|
cee38533b1ef | 2026-01-30 | Fix for Open Team Membership in OwnerActorField and error messaging (#107333) |
07e8bf886fe2 | 2026-01-21 | SentryApps status fix (#105911) |
17dab082e778 | 2025-11-17 | Upgrade Django to avoid CVE-2025-64459 (#103442) |
Endpoint Security Patterns
Contents
- Authorization flow
- Common IDOR patterns (with real examples)
- Base class requirements
- convert_args() scoping
Authorization Flow
Every Sentry API request follows this flow:
dispatch() → initial() → request.access set → convert_args() → handler methodconvert_args() resolves URL kwargs to objects AND runs permission checks via check_object_permissions(). The handler method receives pre-validated objects in kwargs.
The #1 Vulnerability: Unscoped Object Lookups
The most common vulnerability is an endpoint that inherits OrganizationEndpoint (which gives it an organization object) but then queries a model using an ID from the request without scoping by that organization.
Real Example: PromptsActivityEndpoint (PR #104990)
Vulnerable code:
class PromptsActivityEndpoint(OrganizationEndpoint):
def get(self, request: Request, **kwargs) -> Response:
project_id = request.GET.get("project_id")
# BUG: project_id from query param, not scoped by org
result_qs = PromptsActivity.objects.filter(
feature=feature, project_id=project_id, user_id=request.user.id
)Fixed code:
class PromptsActivityEndpoint(OrganizationEndpoint):
def get(self, request: Request, organization: Organization, **kwargs) -> Response:
project_id = request.GET.get("project_id")
# Validate project belongs to this org
if not Project.objects.filter(id=project_id, organization_id=organization.id).exists():
return Response({"detail": "Project not found"}, status=404)
# Scope query by organization
result_qs = PromptsActivity.objects.filter(
feature=feature, project_id=project_id, user_id=request.user.id,
organization_id=organization.id
)Key tell: The handler method did not accept organization as a parameter, meaning it never used the org from the URL.
Real Example: OrganizationEventsEndpoint (PR #104987)
Vulnerable code:
# DashboardWidget ID from query param, not scoped by org
widget = DashboardWidget.objects.get(id=widget_id)Fixed code:
widget = DashboardWidget.objects.get(
id=widget_id,
dashboard__organization_id=organization.id
)Real Example: conditionGroupId IDOR (PR #108156)
Vulnerable code:
# condition_group_id from request body used directly
# Allowed injecting conditions from another org's workflow
class AbstractDataConditionValidator(serializers.Serializer):
condition_group_id = serializers.IntegerField(required=False)Fixed code:
# Removed condition_group_id from user input entirely
# Server sets it in BaseDataConditionGroupValidator instead
class BaseDataConditionGroupValidator(serializers.Serializer):
def validate(self, data):
# Always set condition_group_id server-side
data["condition_group_id"] = self.context["condition_group"].id
return dataChecklist for Endpoint Review
□ Handler method accepts organization/project from kwargs (not just **kwargs)
□ Every ID from request (query params, body, headers) is scoped:
- By organization_id from URL, OR
- By project_id that was itself scoped by org, OR
- Via self.get_projects() which scopes internally
□ IDs from request body are not used to set foreign keys without validation
□ The endpoint's permission_classes match the sensitivity of the operation
□ For PUT/POST/DELETE: the object being modified is scoped by org/projectGuard Queries vs. Data Queries
Not all unscoped queries are exploitable. Before flagging an unscoped query, determine whether it is a data query or a guard query:
Data query — fetches an object whose attributes are returned to the caller (in a Response, serializer, or side effect). An unscoped data query is a real IDOR because the attacker receives cross-org information.
Guard query — checks for the existence of a record only to raise an error or block access. The query result is never returned to the caller.
A guard query is not exploitable when:
1. It only raises an error (e.g., ResourceDoesNotExist, PermissionDenied) 2. A downstream query in the same request flow IS org-scoped 3. The downstream query raises the same error class for the same input 4. Therefore the attacker observes identical responses regardless of the guard
Example (not exploitable):
# Guard query — no org scope, but only raises an error
invite = OrganizationMemberInvite.objects.filter(organization_member_id=om_id).first()
if invite is not None:
raise ResourceDoesNotExist # ← same error as below
# Data query — properly org-scoped
return OrganizationMember.objects.filter(id=om_id, organization_id=org.id).get()
# ↑ DoesNotExist → ResourceDoesNotExistExample (exploitable — still flag this):
# Unscoped query whose result is RETURNED to the caller
widget = DashboardWidget.objects.get(id=widget_id) # ← no org scope
return Response(serialize(widget)) # ← attacker gets cross-org dataNote: Even when a guard query is not exploitable, adding org scoping is valid defense-in-depth. But it should not be reported as a finding.
Using self.get_projects()
When project IDs come from the request, always use self.get_projects():
# WRONG: Direct query bypasses permission checks
project = Project.objects.get(id=request.data["project_id"])
# RIGHT: Uses org-scoped permission-checked helper
projects = self.get_projects(
request=request,
organization=organization,
project_ids={int(request.data["project_id"])}
)self.get_projects() filters by organization_id, checks team membership, and validates the user can access the requested projects.
Base Class Requirements
| Endpoint Type | Base Class | Provides | Permission Default |
|---|---|---|---|
| Org-scoped | OrganizationEndpoint | organization in kwargs | OrganizationPermission (org:read for GET) |
| Project-scoped | ProjectEndpoint | organization + project in kwargs | ProjectPermission |
| Cell silo | CellSiloEndpoint | Nothing — must implement own auth | None |
| Control silo | ControlSiloEndpoint | Nothing — must implement own auth | None |
If an endpoint inherits CellSiloEndpoint or Endpoint directly instead of OrganizationEndpoint/ProjectEndpoint, verify it has its own authorization logic.
Enforcement Layers
Contents
- Where security checks live in a Sentry request
- Layer descriptions and key files
- Tracing requirements
Overview
Sentry enforces security checks across multiple layers. A check in any layer counts as enforcement. Before reporting a missing check, verify it does not exist in any of the layers below.
Request Lifecycle
1. Authentication class → authenticate() / authenticate_token()
2. Permission class → has_permission()
3. convert_args() → resolve URL kwargs → has_object_permission()
4. Access module → determine_access() → from_rpc_auth() / from_request()
5. Handler method → get() / post() / put() / delete()
6. Business logic classes → Validator, Refresher, GrantExchanger, etc.
7. Serializer → validate_*() methods, FK scopingLayer Details
Layer 1: Authentication (api/authentication.py)
Verifies identity and token validity. May also enforce scoping (e.g., UserAuthTokenAuthentication checks scoping_organization_id at lines 530-555).
Not all authentication classes enforce scoping — some delegate to downstream layers.
Layer 2: Permission class (api/permissions.py)
has_permission() runs during DRF's initial(). Checks scope strings (e.g., org:read, project:write). Does not check object-level access.
Layer 3: convert_args() (api/bases/*.py)
Resolves URL kwargs (e.g., organization_id_or_slug) into model objects. Calls check_object_permissions() which triggers has_object_permission().
Layer 4: Access module (auth/access.py)
determine_access() is called from convert_args() in organization/project base endpoints. For org auth tokens, from_rpc_auth() compares auth.organization_id against the requested organization — returns NoAccess() on mismatch, which blocks all scope checks.
Key functions:
| Function | File | What it checks |
|---|---|---|
from_rpc_auth() | auth/access.py | Org auth token's organization_id matches requested org |
from_request() | auth/access.py | Session-based access with org membership |
determine_access() | api/bases/organization.py | Dispatches to the right access builder |
Layer 5: Handler method
The endpoint's get()/post()/etc. May perform additional checks specific to the operation.
Layer 6: Business logic classes
Classes like Validator, ManualTokenRefresher, GrantExchanger, and Refresher in sentry_apps/token_exchange/ run their own validation before performing operations.
Key class: Validator (sentry_apps/token_exchange/validator.py) checks:
- User is a SentryApp proxy user
- App is owned by the requesting user
ApiApplication.is_active(added in PR #105269)- Installation matches the app
Layer 7: Serializer
validate_*() methods and field-level validators may enforce org/project scoping on FK references.
Non-DRF Views
OAuth views (OAuthAuthorizeView, OAuthDeviceView, OAuthTokenView) are plain Django views, not DRF endpoints. Layers 1–4 do not apply to the view itself. Check the view's own authentication decorators, dispatch(), and handler logic directly.
However, tokens issued by these views are later used at DRF API endpoints where layers 1–7 do apply. See "Cross-Flow Enforcement" below.
Cross-Flow Enforcement
For token and credential issuance, enforcement may exist in a different request flow than the one being reviewed:
- Issuance flow: The OAuth authorize/token view that creates the credential
- Usage flow: The DRF API endpoints where the credential is subsequently used
If the issued credential cannot be used because a separate enforcement point blocks it, classify based on where the enforcement lives:
- Centralized enforcement — the check runs in a permission class inherited by all endpoints within the affected scope. The credential cannot reach any endpoint that lacks the check. Classify as LOW (do not report).
- Scattered enforcement — the check exists in some endpoints or serializers but not all. The credential may be usable against unchecked endpoints. Classify as MEDIUM (report as needs verification).
Example (LOW — centralized): OAuth authorize view issues a token to a member-limit:restricted member. The token exists, but is_member_disabled_from_limit() in OrganizationPermission.determine_access() rejects it at every organization-scoped DRF endpoint. Since the token is only usable against organization endpoints (which all inherit this permission class), the enforcement covers all relevant paths. Do not report.
Example (MEDIUM — scattered): A token is issued without checking X, and X is only validated in specific endpoint subclasses (not the base). Some endpoints may not inherit the check. Report as needs verification.
Tracing Requirements
Before marking a finding as HIGH, confirm the check is absent from all layers AND from cross-flow enforcement:
□ Authentication class does not enforce it
□ Permission class does not enforce it
□ convert_args() / has_object_permission() does not enforce it
□ Access module (from_rpc_auth / from_request) does not enforce it
□ Handler method does not enforce it
□ Business logic classes do not enforce it
□ Serializer does not enforce it
□ Cross-flow: the issued credential is not blocked at usage timeIf the check exists in any layer or in a cross-flow enforcement point, the finding is either invalid, LOW (if enforcement is centralized in a base class), or at most MEDIUM (if enforcement is scattered or fragile).
Output Sanitization Patterns
Contents
- Email template injection
- Markdown rendering
- HTML safety
Email Template Injection
User-controlled strings (display names, team names, org names) rendered in email templates can contain HTML that gets interpreted by email clients.
Real vulnerability: Display name injection (PR #108165, #108154)
User display names in invite and team access request emails were not sanitized. An attacker could set their display name to HTML that would render in the recipient's email client.
Vulnerable pattern:
# User display name inserted directly into email context
context = {
"requester_name": requesting_user.get_display_name(),
# ... rendered in HTML email template without escaping
}Fixed pattern:
from django.utils.html import escape
context = {
"requester_name": escape(requesting_user.get_display_name()),
}Where to look
Any code that puts user-provided strings into email context:
get_display_name(),get_username(),namefields- Team names, organization names, project names
- Any string that a user can set that later appears in an email
Search pattern:
grep -rn "get_display_name\|get_username\|\.name" --include="*.py" src/sentry/notifications/
grep -rn "get_display_name\|get_username\|\.name" --include="*.py" src/sentry/mail/Markdown Rendering
Real vulnerability: Custom CSS in marked (PR #106368)
The markdown renderer allowed custom CSS through <style> tags, enabling CSS-based attacks.
What to check:
- Markdown rendering configuration: does it strip HTML tags?
- Are
<style>,<script>, or<iframe>tags allowed? - Is
mark_safe()called on markdown output without sanitization?
HTML Safety in Django
format_html() vs string concatenation
# WRONG: String concatenation — XSS if user_name contains HTML
html = f"<p>Hello {user_name}</p>"
return mark_safe(html)
# RIGHT: format_html escapes parameters
from django.utils.html import format_html
html = format_html("<p>Hello {}</p>", user_name)mark_safe() with user input
mark_safe() should never be called on strings containing user input. Search for:
grep -rn "mark_safe" --include="*.py" src/sentry/Flag any usage where the string argument includes user-controlled data.
Checklist
□ User display names are escaped before use in email templates
□ Team/org/project names are escaped in email context
□ Markdown renderer strips HTML tags (especially style, script, iframe)
□ mark_safe() is never called on user-provided data
□ format_html() is used instead of string concatenation for HTMLPrivilege Escalation Patterns
Contents
- Role hierarchy enforcement
- Team role manipulation
- Superuser/staff checks
Role Hierarchy
Sentry's organization roles from lowest to highest:
member → admin → manager → ownerTeam roles:
contributor → adminRule: A user can only modify roles at or below their own level.
Real vulnerability: Role downgrade by low-privilege users (PR #98213, #108288)
Contributors could downgrade org admins' team roles. Low-privilege members could downgrade other members' roles.
Pattern to check: Any endpoint that modifies a user's role must verify:
1. The requesting user's role is >= the target user's current role 2. The requesting user's role is >= the new role being set
# WRONG: No role comparison
def update_member_role(self, request, member):
member.role = request.data["role"]
member.save()
# RIGHT: Verify requesting user has sufficient role
def update_member_role(self, request, member):
new_role = request.data["role"]
requesting_member = OrganizationMember.objects.get(
user_id=request.user.id, organization_id=member.organization_id
)
# Can't set a role higher than your own
if roles.get(new_role).priority > roles.get(requesting_member.role).priority:
raise PermissionDenied()
# Can't modify someone with a higher role than yours
if roles.get(member.role).priority > roles.get(requesting_member.role).priority:
raise PermissionDenied()
member.role = new_role
member.save()Superuser vs Staff Checks
Real vulnerability: Superuser check instead of active staff (PR #105140)
Replay endpoints checked for is_superuser instead of is_active_staff(), which doesn't verify the superuser session is active.
Correct patterns:
from sentry.auth.superuser import is_active_superuser
from sentry.auth.staff import is_active_staff
# WRONG: Checks the flag, not the active session
if request.user.is_superuser:
...
# RIGHT: Checks active superuser session
if is_active_superuser(request):
...
# RIGHT: Checks active staff session
if is_active_staff(request):
...Superuser Privilege Management
Real vulnerability: No org membership check for privilege changes (PR #106877)
Staff could change superuser/staff privileges without verifying the target user was a member of the default org.
Pattern to check: Privilege escalation operations (granting superuser, staff) must verify org membership and other preconditions.
Checklist
□ Role modification compares requesting user's role to target's current role
□ Role modification compares requesting user's role to the new role being set
□ Superuser checks use is_active_superuser(request), not request.user.is_superuser
□ Staff checks use is_active_staff(request)
□ Privilege changes verify org membership preconditions
□ Team role changes respect org-level role hierarchySerializer Security Patterns
Contents
- ActorField vs OwnerActorField
- Foreign key fields in serializers
- Request body ID validation
ActorField vs OwnerActorField
ActorField accepts any user or team ID without validating the requesting user's relationship to that actor. OwnerActorField additionally checks that the requesting user is a member of the target team.
Location: src/sentry/api/fields/actor.py
When to use which
Default to OwnerActorField for any write-op field accepting a team or user reference (assignment, ownership, delegation). Originally PR #106074.
One known exception: GroupValidator.assignedTo uses ActorField. Issue assignment is a label — it doesn't grant access, and project-access is validated separately in validate_assignedTo. Don't expand this exception without explicit review.
Real vulnerability: ActorField for ownership (PR #106074)
Vulnerable code:
class IssueAlertRuleSerializer(serializers.Serializer):
owner = ActorField(required=False) # BUG: Any team, no membership checkFixed code:
class IssueAlertRuleSerializer(serializers.Serializer):
owner = OwnerActorField(required=False) # Validates team membershipWhat OwnerActorField validates
1. If org has allow_joinleave flag → any team is allowed (user could join anyway) 2. If user has team:admin scope → any team is allowed 3. If user is a member of the target team → allowed 4. Otherwise → ValidationError("You can only assign teams you are a member of")
Finding existing uses
grep -rn "ActorField" --include="*.py" src/sentry/api/Foreign Key IDs in Request Bodies
When a serializer accepts an ID that references another model, the serializer or view must validate that the referenced object belongs to the same organization.
Pattern: Unvalidated FK reference
class MySerializer(serializers.Serializer):
related_id = serializers.IntegerField()
# BUG: No validation that related_id belongs to same orgPattern: Validated FK reference
class MySerializer(serializers.Serializer):
related_id = serializers.IntegerField()
def validate_related_id(self, value):
organization = self.context["organization"]
if not RelatedModel.objects.filter(
id=value, organization_id=organization.id
).exists():
raise serializers.ValidationError("Related object not found")
return valueReal vulnerability: conditionGroupId (PR #108156)
A serializer accepted condition_group_id from user input, allowing a user to inject conditions from another organization's workflow.
Fix: Remove the field from user input entirely. Set it server-side from the validated context.
Checklist
□ Owner/assignee fields use OwnerActorField (exception: issue assignment uses ActorField)
□ FK ID fields in request body are validated against the org
□ IDs that should be server-set are not exposed in the serializer
□ Serializer context includes organization for validationToken & Session Security Patterns
Contents
- Token refresh validation
- Org-level token scoping
- Member status checks
- Impersonation controls
Token Refresh Validation
Real vulnerability: Inactive app token refresh (PR #105269)
SentryApps that were deactivated (unpublished, disabled) could still refresh their API tokens.
Pattern to check: Any token refresh flow must verify the application/integration is still active before issuing a new token. The check may live in the authentication class, the endpoint handler, OR a downstream business logic class — trace the full chain before reporting.
Known enforcement point: Validator._validate_application_is_active() in sentry_apps/token_exchange/validator.py checks ApiApplication.is_active. This is called by ManualTokenRefresher, Refresher, and GrantExchanger before issuing tokens.
# WRONG: Refresh without checking app status anywhere in the chain
def refresh_token(self, request):
app = SentryApp.objects.get(id=token.application.sentry_app_id)
new_token = rotate_token(token)
return Response({"token": new_token})
# RIGHT: Check active status (can be in auth class, handler, or business logic)
def refresh_token(self, request):
app = SentryApp.objects.get(id=token.application.sentry_app_id)
if app.status != SentryAppStatus.PUBLISHED:
return Response({"detail": "Application is not active"}, status=403)
new_token = rotate_token(token)
return Response({"token": new_token})Org-Level Token Scoping
Real vulnerability: Missing organization_id (PR #105064)
OAuth applications with org-level access did not require organization_id, allowing tokens to be used across organizations.
Pattern to check: Token issuance and refresh endpoints for org-scoped tokens must require and validate organization_id before granting access.
This does NOT apply to authentication classes that read existing tokens. For OrgAuthTokenAuthentication, org scoping is enforced by the permission layer: from_rpc_auth() in auth/access.py compares auth.organization_id against the requested organization and returns NoAccess() on mismatch. Do not report OrgAuthTokenAuthentication as missing org scoping — it is enforced downstream.
Member Status Checks
Member disabled states in Sentry
| State | Field / Flag | Can log in? | Reachable in OAuth? | Where enforced |
|---|---|---|---|---|
| Account deactivated | OrganizationMember.user_is_active=False | No | No — login blocked | Login flow |
| Pending invitation | OrganizationMember.is_pending | No | No — requires login | Login flow |
| Seat-limit restricted | OrganizationMember.flags["member-limit:restricted"] | Yes | Yes | OrganizationPermission.determine_access() via is_member_disabled_from_limit() |
The seat-limit restricted state is the one that matters for OAuth and token issuance reviews. The user can still log in and complete an OAuth flow, but all organization-scoped DRF endpoints block the resulting token via is_member_disabled_from_limit() in OrganizationPermission.
Real vulnerability: Disabled member tokens (PR #92616)
Disabled organization members could still request auth tokens.
Pattern to check: Before issuing any token, verify the member is not disabled:
# Check member status
member = OrganizationMember.objects.get(
user_id=request.user.id,
organization_id=organization.id
)
if member.is_pending or not member.user_is_active:
return Response({"detail": "Member is not active"}, status=403)Known downstream enforcement: PR #92616 added is_member_disabled_from_limit() checks via the organization permission base class. This is centralized enforcement — the check runs for every organization-scoped DRF endpoint via OrganizationPermission.determine_access(). Tokens held by seat-limit restricted members are blocked at all organization API endpoints. Because the enforcement covers all endpoints the token can be used against, this pattern is LOW (do not report). See enforcement-layers.md "Cross-Flow Enforcement."
Real vulnerability: Personal tokens managing org tokens (PR #99457)
Personal API tokens could perform actions on organization auth tokens, bypassing org-level authorization.
Pattern to check: Org token management endpoints must verify the auth method is appropriate (org-level auth, not personal token).
Impersonation Rate Limiting
Real vulnerability: No rate limits on impersonation (PR #106814)
Impersonated sessions (staff acting as a user) had no rate limiting, allowing unrestricted API access.
Pattern to check: Impersonated sessions should have rate limits applied.
Checklist
□ Token refresh checks application/integration active status (in auth class, handler, OR business logic)
□ Token issuance/refresh endpoints for org-scoped tokens require and validate organization_id
(authentication classes reading existing tokens are exempt — org scoping is enforced by from_rpc_auth())
□ Member active status is checked before token issuance
If missing at issuance but enforced at usage via is_member_disabled_from_limit() in OrganizationPermission → LOW (centralized, do not report)
If enforced only in specific endpoint subclasses → MEDIUM
□ Auth method is appropriate for the operation (org token vs personal token)
□ Impersonated sessions are rate-limited
□ Token revocation cascades properly (revoking app revokes all its tokens)Related skills
FAQ
What does sentry-security do?
Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check for IDOR", "access c
When should I use sentry-security?
Sentry-specific security review based on real vulnerability history. Use when reviewing Sentry endpoints, serializers, or views for security issues. Trigger keywords: "sentry security review", "check for IDOR", "access c
Is sentry-security safe to install?
Review the Security Audits panel on this page before installing in production.