Skip to content

Hackweek: Add Gitea SCM support - #122201

Draft
ericapisani wants to merge 2 commits into
masterfrom
ep/hackweek-gitea
Draft

Hackweek: Add Gitea SCM support#122201
ericapisani wants to merge 2 commits into
masterfrom
ep/hackweek-gitea

Conversation

@ericapisani

@ericapisani ericapisani commented Aug 18, 2026

Copy link
Copy Markdown
Member

Support Gitea as an SCM provider.

Done during hackweek so most of this is built with vibes.

Successfully tested linking and issue creation against a test repo hosted on gitea.com. Still need to test against a self-hosted instance.

Everything is currently in this (frontend, backend, and static changes) which is why it's over 6k lines

Related to #15738

@github-actions github-actions Bot added Scope: Frontend Automatically applied to PRs that change frontend components Scope: Backend Automatically applied to PRs that change backend components labels Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🚨 Warning: This pull request contains Frontend and Backend changes!

It's discouraged to make changes to Sentry's Frontend and Backend in a single pull request. The Frontend and Backend are not atomically deployed. If the changes are interdependent of each other, they must be separated into two pull requests and be made forward or backwards compatible, such that the Backend or Frontend can be safely deployed independently.

Have questions? Please ask in the #discuss-dev-infra channel.

# 5xx here would have Gitea - and our own webhookpayload retries -
# redeliver something that can never succeed.
extra["webhook.reason"] = GITEA_WEBHOOK_SECRET_MISSING_ERROR
logger.warning("gitea.webhook.missing-webhook-secret", extra=extra)
# malformed one is user error rather than an integration
# failure.
lifecycle.record_halt(e)
return Response({"detail": str(e)}, status=400)
return Response({"detail": str(e)}, status=400)
except ApiError as e:
lifecycle.record_failure(e)
return Response({"detail": str(e)}, status=400)
repos = installation.get_repositories(query=query)
except (ApiError, IntegrationError) as e:
lifecycle.record_failure(e)
return Response({"detail": str(e)}, status=400)
@ericapisani ericapisani added the Do Not Merge Don't merge label Aug 18, 2026
@ericapisani ericapisani changed the title Gitea integration Hackweek: Add Gitea SCM support Aug 18, 2026
Dispatches `GiteaProvider` from `fetch_service_provider` and derives the
`web_base_url` it needs in `fetch_repository`. Together these are the
last hop that lets Seer/autofix act on Gitea repositories: the
integration, client, and repository provider already existed, but
nothing told the SCM platform to wrap that client in an `scm` provider,
so `fetch_service_provider` fell through to `None` and every call
surfaced as `ProviderNotFound`.

`web_base_url` comes from `metadata["base_url"]` rather than `instance`
or `domain_name`. Gitea's ROOT_URL is free-form and may include a
sub-path (`https://host/gitea/`), and the other two keys are
hostname-only, so using them would silently drop it and point every API
call at the wrong URL. A repository that somehow lacks the base URL
returns `None` rather than letting the provider raise, matching how this
module already handles unusable repository state.

Requires sentry-scm with the Gitea provider.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WVBi2Vg8XYgpADRiSZw1Lr
Comment on lines +330 to +339
def get_issue(self, repo_path: str, issue_index: str | int) -> Any:
"""https://docs.gitea.com/api/1.22/#tag/issue/operation/issueGetIssue"""
return self.get(GiteaApiPath.issue.format(repo=repo_path, issue_index=issue_index))

def create_issue_comment(
self, repo_path: str, issue_index: str | int, data: dict[str, Any]
) -> Any:
"""https://docs.gitea.com/api/1.22/#tag/issue/operation/issueCreateComment"""
return self.post(
GiteaApiPath.issue_comments.format(repo=repo_path, issue_index=issue_index), data=data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unvalidated Gitea issue index bypasses the linked-repository boundary

externalIssue is interpolated into the Gitea issue URL without rejecting relative path segments, allowing an organization member to use a value such as ../../../other-owner/other-repo/issues/1 to read an issue from another repository accessible to the installation token, even though only the submitted repo is checked against the organization's linked repositories. Validate issue indexes as numeric values or reject dot segments before constructing the API path.

Evidence
  • The authenticated link endpoint passes caller-controlled request.data to GiteaIssuesSpec.get_issue(), which validates only data["repo"]; externalIssue is checked only for non-emptiness before being passed to GiteaApiClient.get_issue().
  • get_issue() formats issue_index directly into /repos/{repo}/issues/{issue_index}. A value such as ../../../other-owner/other-repo/issues/1 escapes the validated repository prefix and resolves to the other repository's issue route.
  • A normal Gitea issue response contains the fields consumed by get_issue(), so the foreign issue's title, body, and URL are persisted in the Sentry external issue and returned to the requester.
  • The raw traversal is not used for the comment POST: after_link_issue() reconstructs the index from external_issue.key, which is generated from the returned issue number and the originally validated repository. The impact is therefore unauthorized reads, not arbitrary comment creation.

Identified by Warden · security-review · CCW-MW4

Comment on lines +194 to +206
def compare_commits(
self, repo: Any, start_sha: str | None, end_sha: str
) -> Sequence[Mapping[str, Any]]:
installation = self.get_installation(repo.integration_id, repo.organization_id)
client = installation.get_client()
repo_path = repo.config["path"]

try:
if start_sha is None:
commits = client.get_commits(repo_path, sha=end_sha)
else:
# `compare/{basehead}` has been available since 1.22.
commits = client.compare_commits(repo_path, start_sha, end_sha)["commits"] or []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gitea compare path traversal can import private repository commits

An authenticated organization user can place traversal characters in previousCommit and query characters in the release commit, causing the Gitea client to escape the linked repository's compare route and use the installation token to compare another repository; returned private commit metadata is then imported into the organization's Sentry release.

Evidence
  • ReleaseHeadCommitSerializer only length-checks previousCommit, while commit accepts non-SHA characters such as ? when it does not contain the range delimiter.
  • release.set_refs() queues fetch_commits, which passes the request-controlled previousCommit and commit through resolve_ref() into GiteaRepositoryProvider.compare_commits().
  • GiteaApiClient.compare_commits() interpolates both values into /repos/{repo}/compare/{start_sha}...{end_sha} without encoding or relative-segment checks. For example, a start value such as ../../../other-owner/other-repo/compare/base and an end value such as head? produces a normalized request for /repos/other-owner/other-repo/compare/base...head.
  • The installation's bearer token authenticates that request, and the resulting commits are formatted and passed to release.set_commits(), allowing metadata from a private repository accessible to the Gitea installer to enter the attacker's Sentry organization.

Identified by Warden · security-review · AVD-TZQ

Comment on lines +161 to +178
def update_repo_data(self, repo: Repository, event: Mapping[str, Any]) -> None:
"""
Keep the stored URL and ``owner/name`` path current - renaming a repo or
moving the instance behind a new ``ROOT_URL`` changes both, and every
API call and stacktrace link is built from them.
"""
repository = event.get("repository") or {}
url_from_event = repository.get("html_url")
path_from_event = repository.get("full_name")

if not url_from_event or not path_from_event:
return

if repo.url != url_from_event or repo.config.get("path") != path_from_event:
repo.update(
url=url_from_event,
config=dict(repo.config, path=path_from_event),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Webhook can poison repository path used in Gitea API routes

Signed push/PR webhooks write repository.full_name into repo.config["path"] with no is_repo_path check, so a path like x/../../user is later interpolated into /repos/{repo}/... and can escape repo scope using the installing user's token. Validate with is_repo_path (and preferably host-match html_url) before updating.

Evidence
  • update_repo_data() assigns webhook repository.full_name / html_url directly onto repo.config["path"] and repo.url with no shape or host checks.
  • PushEventWebhook / PullRequestEventWebhook call update_repo_data() after HMAC verification; Gitea repo admins can read the hook secret and forge that signature (noted in GiteaApiClient.webhook_secret).
  • Downstream callers (GiteaApiClient.repo_path, compare_commits, webhook create/delete, stacktrace file fetch) put repo.config["path"] raw into /repos/{repo}/....
  • is_repo_path / has_relative_segments exist because Gitea resolves .. out of repo scope (e.g. toward /api/v1/user), and are enforced on link/issue form input but not on this webhook write path.
Also found at 1 additional location
  • src/sentry/integrations/gitea/webhooks.py:202-202

Identified by Warden · security-review · 383-5FJ

# exception, so the auth failure has to be spotted by hand.
return self._attempt_request_after_refreshing_token(*args, **kwargs)

self._track_rate_limit(response)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_track_rate_limit crashes on 204 responses from DELETE requests

_track_rate_limit unconditionally accesses response.headers, but the parent BaseApiClient returns {} for HTTP 204 responses, causing AttributeError when deleting webhooks or on any other 204-returning endpoint.

Evidence
  • BaseApiClient._request returns {} for any response with status code 204.
  • delete_repo_webhook calls self.delete() which routes through _issue_request_with_auto_token_refresh, falling through to _track_rate_limit(response) on success.
  • get_rate_limit_info_from_response accesses response.headers, which plain dicts do not have, producing AttributeError at runtime.
Also found at 1 additional location
  • src/sentry/integrations/gitea/client.py:63-63

Identified by Warden · sentry-backend-bugs · 8ZQ-668

Comment on lines +50 to +52
# `/repos/{repo}` onto an unrelated route with the install user's token.
repo_path = config["identifier"]
if not isinstance(repo_path, str) or not is_repo_path(repo_path):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing identifier key causes 500 instead of validation error

config["identifier"] is accessed directly from the request body without checking existence. If the key is missing, KeyError becomes a 500 response via handle_api_error instead of the intended 400.

Evidence
  • The config parameter originates as request.data in IntegrationRepositoryProvider.dispatch (integration_repository.py:324).
  • repo_path = config["identifier"] raises KeyError before the isinstance/is_repo_path validation on the next line.
  • dispatch catches the exception but its handle_api_error returns a 500 for anything that is not IntegrationError or Integration.DoesNotExist.
  • Raising IntegrationError here would produce a 400, but the KeyError fires first and bypasses it.

Identified by Warden · sentry-backend-bugs · 9LJ-JV6

"client_id": installation_data.get("client_id"),
"error_status": getattr(resp, "status_code"), # error might not be an HTTP error
"error_message": f"{e}",
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

get_user_info assumes API response is always valid JSON

resp.json() is called outside the try/except block. A 200 response carrying HTML from a proxy or misconfigured Gitea instance will raise an unhandled JSONDecodeError instead of being logged and re-raised gracefully.

Evidence
  • get_user_info() makes an HTTP request to the customer's Gitea instance.
  • resp.raise_for_status() is wrapped in try/except, but return resp.json() on line 65 is outside that block.
  • A proxy or misconfigured instance can return HTTP 200 with an HTML body, which passes raise_for_status() and then crashes on .json().
  • The caller build_integration() in integrations/gitea/integration.py does not catch JSONDecodeError, so the exception propagates unhandled.
  • refresh_identity() in the same file already wraps orjson.loads() in try/except for exactly this scenario, confirming the risk is real.

Identified by Warden · sentry-backend-bugs · KBE-DDP

Comment on lines +307 to +318

def get_file(
self, repo: Repository, path: str, ref: str | None, codeowners: bool = False
) -> str:
"""
File contents.

``/raw`` hands back the bytes directly, saving the base64 round trip
that ``/contents`` would require.
"""
contents = self.get(
GiteaApiPath.raw.format(repo=self.repo_path(repo), path=quote_path(path)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

get_file decodes error response bodies as file contents when raw_response=True bypasses raise_for_status

get_file returns the decoded body of a failed request as file contents because raw_response=True skips status-code checking and the method never verifies contents.ok.

Evidence
  • self.get(..., raw_response=True) returns the raw requests.Response object without calling raise_for_status(), so 4xx/5xx responses are returned directly to the caller.
  • contents.content.decode('utf-8') is executed unconditionally, turning an error payload (e.g. 404 HTML) into the return value.
  • The caller SCMIntegration.get_codeowner_file wraps this call in try/except ApiError and expects an exception on failure; when Gitea silently returns error HTML the caller proceeds with corrupt CODEOWNERS data.
  • GitHub's client in the same codebase (src/sentry/integrations/github/client.py:1105) explicitly checks contents.ok and raises ApiError.from_response(contents) before decoding when using raw_response=True.

Identified by Warden · sentry-backend-bugs · T4Q-QKV

Comment on lines +61 to +69

config.update(
{
"instance": instance,
"path": repo["full_name"],
"name": repo["full_name"],
"external_id": installation.get_repo_external_id(repo),
"url": repo["html_url"],
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gitea API response fields accessed outside error-handling block

repo["full_name"], repo["html_url"], and repo["id"] (via get_repo_external_id) are read after the try/except that only wraps the network call. A successful but malformed API response raises an unhandled KeyError that returns 500.

Evidence
  • client.get_repo(repo_path) is the only call inside the try/except block.
  • The config.update(...) block immediately after accesses repo["full_name"] and repo["html_url"] directly.
  • installation.get_repo_external_id(repo) calls repo["id"] internally.
  • If Gitea returns a 200 with an incomplete or unexpected payload, any missing key becomes an unhandled KeyError.
  • dispatch maps unhandled exceptions to a 500 response.

Identified by Warden · sentry-backend-bugs · MPV-HHV

Comment on lines +327 to +338
provider_updated_at = parse_scm_timestamp(pull_request.get("updated_at"))

defaults = {
"title": title,
"author": author,
"message": pull_request.get("body") or "",
"merge_commit_sha": merge_commit_sha,
"head_commit_sha": (pull_request.get("head") or {}).get("sha"),
"date_added": parse_scm_timestamp(pull_request.get("created_at")),
"opened_at": parse_scm_timestamp(pull_request.get("created_at")),
"closed_at": parse_scm_timestamp(pull_request.get("closed_at")),
"merged_at": parse_scm_timestamp(pull_request.get("merged_at")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request webhook handler crashes on malformed timestamp strings

Raw parse_scm_timestamp calls on unvalidated Gitea webhook fields raise ValueError or TypeError for non-date strings, bypassing the existing IntegrityError guard and returning a 500 that triggers redelivery.

Evidence
  • parse_scm_timestamp delegates to dateutil.parser.parse, which raises ValueError or TypeError for invalid or non-string input.
  • Lines 327 and 335-338 call it on raw pull_request.get(...) values without any preceding validation.
  • The surrounding try/except IntegrityError only guards update_pull_request_from_scm_snapshot, so parser exceptions propagate uncaught.
  • PushEventWebhook in the same file already wraps parse_date in except (KeyError, TypeError, ValueError) at line 226, confirming this risk is known for the push path.

Identified by Warden · sentry-backend-bugs · X57-MJX

Comment on lines +100 to +103
try:
data = orjson.loads(self.request.body)
except orjson.JSONDecodeError:
data = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-dict JSON body crashes mailbox routing with AttributeError

orjson.loads can return lists, strings, or null — valid JSON that is not an object. When this happens mailbox_bucket_id calls .get() on a non-dict and raises AttributeError, causing a 500 response to the webhook sender and triggering retries. The endpoint handler already guards against this, but the middleware parser was missed.

Evidence
  • get_response_from_gitea_webhook parses self.request.body with orjson.loads on line 101 and only catches JSONDecodeError; a bare list ([]), string, or null passes through unchanged.
  • The parsed data is passed to get_mailbox_identifier on line 107, which invokes _build_bucketed_identifiermailbox_bucket_id.
  • mailbox_bucket_id on line 118 does data.get("repository"), which raises AttributeError when data is not a dict.
  • GiteaWebhookEndpoint.post in the same PR contains an identical orjson.loads and explicitly validates isinstance(event, dict) with a comment noting that a bare list/string "blows up on the first .get inside a handler" and produces a 500.
Also found at 1 additional location
  • src/sentry/middleware/integrations/classifications.py:51

Identified by Warden · sentry-backend-bugs · WHM-JYR

prepared_request.headers["Authorization"] = f"Bearer {self.token}"
return prepared_request

@control_silo_function

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gitea issue-linking GET traverses repository boundary via unvalidated externalIssue

GiteaApiClient.get_issue formats the caller-supplied issue_index directly into /repos/{repo}/issues/{issue_index} without validation, so a value such as ../../../other-owner/other-repo/issues/1 escapes the repository scoping enforced by _validated_repo and reads an arbitrary issue with the integration bearer token.

Evidence
  • GiteaIssuesSpec.get_issue validates the repo parameter via _validated_repo (shape check plus active-installation ownership), but only checks that externalIssue is non-empty before it passes the raw string to GiteaApiClient.get_issue.
  • GiteaApiClient.get_issue at lines 330-332 interpolates issue_index straight into the path template GiteaApiPath.issue with .format(). No validation, quoting, or has_relative_segments guard is applied to the index.
  • BaseApiClient._request builds the full URL and hands it to requests, which transmits .. path segments verbatim. Go’s HTTP server (and common proxies such as nginx) normalize the path, resolving the request to a different repository.
  • The installation bearer token is included in the request, so if the Gitea user can read the target repository, the cross-repo issue is returned to the Sentry caller.

Identified by Warden · wrdn-authz · ZQB-MSC

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

Labels

Do Not Merge Don't merge Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants