Skip to content

Commit 9d5b6ab

Browse files
fix(security): Require a webhook signature and keep workflows in the payload's repo (#126)
* fix(security): Require a webhook signature and keep workflows in the payload's repo Without GH_WEBHOOK_SECRET the app accepted unsigned payloads, so anyone could make it mint a token for any installation and read any GitHub API path with it. Reject every payload when the secret is unset, and treat a missing signature header as invalid instead of raising. Also reject payloads whose run_url, or workflow_run repository, points outside the payload's repository, or whose owner doesn't match the repository. The DSN comes from the owner's .sentry repo, so a mismatch would send one repo's data to another org's Sentry project. Co-Authored-By: Armen Zambrano Gasparnian <44410+armenzg@users.noreply.github.com> * fix(security): Reject repository names that hold a path Only the owner part of repository.full_name was checked, so a name like getsentry/../victim/private passed and ended up in the API URL. --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Armen Zambrano Gasparnian <44410+armenzg@users.noreply.github.com>
1 parent 3f4fb33 commit 9d5b6ab

4 files changed

Lines changed: 154 additions & 20 deletions

File tree

‎docs/deployment.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A Sentry project tracks errors of the backend itself. Its DSN is `APP_DSN`.
1313
| Variable | Purpose |
1414
| --- | --- |
1515
| `APP_DSN` | Optional. Reports the app's own errors and performance to Sentry. |
16-
| `GH_WEBHOOK_SECRET` | Secret shared with the GitHub webhook. When unset, the app accepts unsigned requests. Generate one with `python3 -c 'import secrets; print(secrets.token_urlsafe(20))'`. |
16+
| `GH_WEBHOOK_SECRET` | Required. Secret shared with the GitHub webhook. The app rejects requests that aren't signed with it, and rejects every request when it's unset. Generate one with `python3 -c 'import secrets; print(secrets.token_urlsafe(20))'`. |
1717
| `GH_APP_ID` | GitHub App ID, shown on the GitHub App page. Setting it turns on GitHub App mode. |
1818
| `GH_APP_PRIVATE_KEY` | Base64-encoded GitHub App private key. On Cloud Run (`K_SERVICE` set) without this variable, the app reads the key from Google Secret Manager instead. See `get_gh_app_private_key` in `src/web_app_handler.py`. |
1919
| `GH_TOKEN` | Optional personal access token. The app uses it when `GH_APP_ID` is unset or the private key fails to load. |

‎docs/development.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The argument must be a job URL. For a job in the `getsentry` org, you can set `I
3737
1. Start the app with `flask run -p 5001`.
3838
2. Start ngrok with `ngrok http 5001` and note the URL.
3939
3. Create a separate GitHub App for development, configured as in [deployment.md](deployment.md#configure-the-github-app), with its webhook URL set to the ngrok URL.
40-
4. Generate a private key for it and set `GH_APP_ID` and `GH_APP_PRIVATE_KEY` locally. Put secrets in `.env`, which `.envrc` loads.
40+
4. Generate a private key for it and set `GH_APP_ID`, `GH_APP_PRIVATE_KEY`, and the app's webhook secret as `GH_WEBHOOK_SECRET` locally. Put secrets in `.env`, which `.envrc` loads.
4141
5. Delete the private key from the GitHub App when you finish.
4242

4343
Fixtures can carry old timestamps. Sentry then shows "Clock drift detected in SDK" on the transaction. You can ignore it.

‎src/web_app_handler.py‎

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,24 @@
44
import hmac
55
import logging
66
import os
7+
import re
78
from typing import NamedTuple
89

910
from sentry_sdk import metrics
1011
from sentry_sdk.utils import BadDsn
1112

1213
from .github_app import GithubAppToken
14+
from .github_sdk import GITHUB_API_URL
1315
from .github_sdk import GithubClient
1416
from src.sentry_config import fetch_config_for_github_org
17+
from src.sentry_config import GITHUB_OWNER_PATTERN
1518

1619
LOGGING_LEVEL = os.environ.get("LOGGING_LEVEL", logging.INFO)
1720
logger = logging.getLogger(__name__)
1821
logger.setLevel(LOGGING_LEVEL)
1922

23+
GITHUB_REPO_NAME_PATTERN = re.compile(r"[A-Za-z0-9._-]+")
24+
2025

2126
def commit_traces_enabled() -> bool:
2227
# Off by default: it costs one GitHub API call per successful workflow run
@@ -42,7 +47,9 @@ def handle_event(self, data, headers):
4247
reason = "Skipped jobs are not traced."
4348
elif not self.dry_run:
4449
reason, http_code = self._send(
45-
data, lambda client, _: client.send_trace(data["workflow_job"])
50+
event,
51+
data,
52+
lambda client, _: client.send_trace(data["workflow_job"]),
4653
)
4754
elif event == "workflow_run":
4855
if not commit_traces_enabled():
@@ -55,6 +62,7 @@ def handle_event(self, data, headers):
5562
reason = "Only successful workflow runs can turn a commit green."
5663
elif not self.dry_run:
5764
reason, http_code = self._send(
65+
event,
5866
data,
5967
lambda client, config: client.send_commit_trace(
6068
data["workflow_run"], config.commit_workflows
@@ -65,7 +73,7 @@ def handle_event(self, data, headers):
6573

6674
return reason, http_code
6775

68-
def _send(self, data, send) -> tuple[str, int]:
76+
def _send(self, event, data, send) -> tuple[str, int]:
6977
# Only GitHub App webhooks carry an installation; repository webhooks don't
7078
installation_id = (data.get("installation") or {}).get("id")
7179
org = data["repository"]["owner"]["login"]
@@ -74,6 +82,8 @@ def _send(self, data, send) -> tuple[str, int]:
7482
"The payload has no installation. Send it from the GitHub App's webhook.",
7583
400,
7684
)
85+
if not _targets_payload_repository(event, data):
86+
return "The payload's workflow doesn't belong to its repository.", 400
7787

7888
try:
7989
# We are executing in Github App mode
@@ -97,16 +107,44 @@ def _send_with_token(self, token, installation_id, org, send):
97107
send(client, config)
98108

99109
def valid_signature(self, body, headers):
110+
# Without a secret anyone could send a payload, and the app would act on it
111+
# with the token of whichever installation the payload names
100112
if not self.config.gh.webhook_secret:
101-
return True
102-
else:
103-
signature = headers["X-Hub-Signature-256"].replace("sha256=", "")
104-
body_signature = hmac.new(
105-
self.config.gh.webhook_secret.encode(),
106-
msg=body,
107-
digestmod="sha256",
108-
).hexdigest()
109-
return hmac.compare_digest(body_signature, signature)
113+
return False
114+
signature = headers.get("X-Hub-Signature-256", "").replace("sha256=", "")
115+
body_signature = hmac.new(
116+
self.config.gh.webhook_secret.encode(),
117+
msg=body,
118+
digestmod="sha256",
119+
).hexdigest()
120+
return hmac.compare_digest(body_signature, signature)
121+
122+
123+
def _targets_payload_repository(event: str, data: dict) -> bool:
124+
"""Whether the workflow the app reads with the token is in the payload's repository.
125+
126+
The DSN comes from the repository owner's .sentry repo, so a workflow from another
127+
repository would send that repository's data to the owner's Sentry project.
128+
"""
129+
repo = data["repository"]["full_name"]
130+
owner, _, name = repo.partition("/")
131+
if owner.lower() != data["repository"]["owner"]["login"].lower():
132+
return False
133+
# The name ends up in API URLs, so it can't hold another path (e.g. `../other/repo`)
134+
if (
135+
not GITHUB_OWNER_PATTERN.fullmatch(owner)
136+
or not GITHUB_REPO_NAME_PATTERN.fullmatch(name)
137+
or name in (".", "..")
138+
):
139+
return False
140+
if event == "workflow_job":
141+
run_url = (
142+
rf"{re.escape(GITHUB_API_URL)}/repos/{re.escape(repo)}/actions/runs/\d+"
143+
)
144+
return bool(
145+
re.fullmatch(run_url, data["workflow_job"]["run_url"], re.IGNORECASE)
146+
)
147+
return data["workflow_run"]["repository"]["full_name"].lower() == repo.lower()
110148

111149

112150
class GithubAppConfig(NamedTuple):
@@ -166,11 +204,15 @@ def init_config():
166204
"We have failed to load the private key, however, we will fallback to the PAT method.",
167205
)
168206

207+
webhook_secret = os.environ.get("GH_WEBHOOK_SECRET")
208+
if not webhook_secret:
209+
logger.error("GH_WEBHOOK_SECRET is not set, so every webhook will be rejected.")
210+
169211
return Config(
170212
gh_app,
171213
GitHubConfig(
172214
# This token is a PAT
173215
token=os.environ.get("GH_TOKEN"),
174-
webhook_secret=os.environ.get("GH_WEBHOOK_SECRET"),
216+
webhook_secret=webhook_secret,
175217
),
176218
)

‎tests/test_web_app_handler.py‎

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def gh_app_env(monkeypatch):
7171
monkeypatch.delenv("K_SERVICE", raising=False)
7272
monkeypatch.setenv("GH_APP_ID", "1")
7373
monkeypatch.setenv("GH_APP_PRIVATE_KEY", base64.b64encode(pem).decode())
74+
monkeypatch.setenv("GH_WEBHOOK_SECRET", "fake_secret")
7475

7576

7677
@responses.activate
@@ -82,7 +83,10 @@ def test_skipped_job_makes_no_api_calls(gh_app_env, skipped_workflow):
8283
data={
8384
"action": "completed",
8485
"installation": {"id": 1},
85-
"repository": {"owner": {"login": "getsentry"}},
86+
"repository": {
87+
"full_name": "getsentry/sentry",
88+
"owner": {"login": "getsentry"},
89+
},
8690
"workflow_job": skipped_workflow,
8791
},
8892
headers={"X-GitHub-Event": "workflow_job"},
@@ -110,7 +114,10 @@ def test_invalid_dsn_is_a_warning(mock_metrics, gh_app_env, jobA_job, caplog):
110114
data={
111115
"action": "completed",
112116
"installation": {"id": 1},
113-
"repository": {"owner": {"login": "getsentry"}},
117+
"repository": {
118+
"full_name": "getsentry/sentry",
119+
"owner": {"login": "getsentry"},
120+
},
114121
"workflow_job": jobA_job,
115122
},
116123
headers={"X-GitHub-Event": "workflow_job"},
@@ -143,10 +150,21 @@ def test_missing_workflow_job(monkeypatch):
143150
assert msg == "workflow_job"
144151

145152

146-
def test_valid_signature_no_secret(monkeypatch):
153+
def test_no_secret_rejects_every_payload(monkeypatch, webhook_event):
147154
monkeypatch.delenv("GH_WEBHOOK_SECRET", raising=False)
148155
handler = WebAppHandler()
149-
assert handler.valid_signature(body={}, headers={}) == True
156+
assert not handler.valid_signature(
157+
body=json.dumps(webhook_event["payload"]).encode(),
158+
headers={"X-Hub-Signature-256": f"sha256={valid_signature}"},
159+
)
160+
161+
162+
def test_missing_signature_is_invalid(monkeypatch, webhook_event):
163+
monkeypatch.setenv("GH_WEBHOOK_SECRET", "fake_secret")
164+
handler = WebAppHandler()
165+
assert not handler.valid_signature(
166+
body=json.dumps(webhook_event["payload"]).encode(), headers={}
167+
)
150168

151169

152170
def test_valid_signature(monkeypatch, webhook_event):
@@ -222,7 +240,10 @@ def test_repository_webhook_in_github_app_mode_is_rejected(gh_app_env, jobA_job)
222240
reason, http_code = handler.handle_event(
223241
data={
224242
"action": "completed",
225-
"repository": {"owner": {"login": "getsentry"}},
243+
"repository": {
244+
"full_name": "getsentry/sentry",
245+
"owner": {"login": "getsentry"},
246+
},
226247
"workflow_job": jobA_job,
227248
},
228249
headers={"X-GitHub-Event": "workflow_job"},
@@ -248,7 +269,10 @@ def test_repository_webhook_uses_the_token(mock_metrics, monkeypatch, jobA_job):
248269
reason, http_code = handler.handle_event(
249270
data={
250271
"action": "completed",
251-
"repository": {"owner": {"login": "getsentry"}},
272+
"repository": {
273+
"full_name": "getsentry/sentry",
274+
"owner": {"login": "getsentry"},
275+
},
252276
"workflow_job": jobA_job,
253277
},
254278
headers={"X-GitHub-Event": "workflow_job"},
@@ -257,3 +281,71 @@ def test_repository_webhook_uses_the_token(mock_metrics, monkeypatch, jobA_job):
257281
assert reason == "The DSN in the org's .sentry/sentry_config.ini is invalid."
258282
assert http_code == 200
259283
assert responses.calls[0].request.headers["Authorization"] == "token pat"
284+
285+
286+
@responses.activate
287+
@pytest.mark.parametrize(
288+
"owner,run_url",
289+
(
290+
# The DSN would come from attacker's .sentry repo
291+
("attacker", "https://api.github.com/repos/getsentry/sentry/actions/runs/1"),
292+
("getsentry", "https://api.github.com/repos/getsentry/private/actions/runs/1"),
293+
(
294+
"getsentry",
295+
"https://api.github.com/repos/getsentry/sentry/actions/runs/1/../../../private/actions/runs/1",
296+
),
297+
("getsentry", "https://api.github.com/repos/getsentry/sentry/actions/secrets"),
298+
),
299+
)
300+
def test_job_outside_the_payload_repository_is_rejected(
301+
gh_app_env, jobA_job, owner, run_url
302+
):
303+
reason, http_code = WebAppHandler().handle_event(
304+
data={
305+
"action": "completed",
306+
"installation": {"id": 1},
307+
"repository": {"full_name": "getsentry/sentry", "owner": {"login": owner}},
308+
"workflow_job": {**jobA_job, "run_url": run_url},
309+
},
310+
headers={"X-GitHub-Event": "workflow_job"},
311+
)
312+
assert reason == "The payload's workflow doesn't belong to its repository."
313+
assert http_code == 400
314+
assert len(responses.calls) == 0
315+
316+
317+
@responses.activate
318+
@pytest.mark.parametrize(
319+
"full_name", ("getsentry/../victim/private", "getsentry/..", "getsentry/a/b")
320+
)
321+
def test_repository_name_with_a_path_is_rejected(gh_app_env, jobA_job, full_name):
322+
reason, http_code = WebAppHandler().handle_event(
323+
data={
324+
"action": "completed",
325+
"installation": {"id": 1},
326+
"repository": {"full_name": full_name, "owner": {"login": "getsentry"}},
327+
"workflow_job": {
328+
**jobA_job,
329+
"run_url": f"https://api.github.com/repos/{full_name}/actions/runs/1",
330+
},
331+
},
332+
headers={"X-GitHub-Event": "workflow_job"},
333+
)
334+
assert reason == "The payload's workflow doesn't belong to its repository."
335+
assert http_code == 400
336+
assert len(responses.calls) == 0
337+
338+
339+
@responses.activate
340+
def test_run_outside_the_payload_repository_is_rejected(monkeypatch, gh_app_env):
341+
with open("tests/fixtures/commit/webhook_payload.json") as f:
342+
payload = json.load(f)
343+
monkeypatch.setenv("COMMIT_TRACES_ENABLED", "true")
344+
payload["workflow_run"]["repository"]["full_name"] = "getsentry/private"
345+
346+
reason, http_code = WebAppHandler().handle_event(
347+
data=payload, headers={"X-GitHub-Event": "workflow_run"}
348+
)
349+
assert reason == "The payload's workflow doesn't belong to its repository."
350+
assert http_code == 400
351+
assert len(responses.calls) == 0

0 commit comments

Comments
 (0)