Skip to content

Commit b0ce038

Browse files
woodruffwdi
authored andcommitted
GitHub OIDC: validate job_workflow_ref (pypi#11263)
* tests, warehouse: validate job_workflow_ref Add a bunch of counterexample tests to be certain. * oidc/models: wrap `re.match` to make mypy happy * tests/oidc: update * warehouse, tests: fix `job_workflow_ref` regex * tests, warehouse: refactor `job_workflow_ref` again * warehouse, tests: refactor `job_workflow_ref` verification again Co-authored-by: Dustin Ingram <di@users.noreply.github.com>
1 parent fd1d9ec commit b0ce038

File tree

2 files changed

+114
-14
lines changed

2 files changed

+114
-14
lines changed

tests/unit/oidc/test_models.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@
1111
# limitations under the License.
1212

1313
import pretend
14+
import pytest
1415

1516
from warehouse.oidc import models
1617

1718

19+
def test_check_claim_binary():
20+
wrapped = models._check_claim_binary(str.__eq__)
21+
22+
assert wrapped("foo", "bar", pretend.stub()) is False
23+
assert wrapped("foo", "foo", pretend.stub()) is True
24+
25+
1826
class TestOIDCProvider:
1927
def test_oidc_provider_not_default_verifiable(self):
2028
provider = models.OIDCProvider(projects=[])
@@ -27,9 +35,9 @@ def test_github_provider_all_known_claims(self):
2735
assert models.GitHubProvider.all_known_claims() == {
2836
# verifiable claims
2937
"repository",
30-
"workflow",
3138
"repository_owner",
3239
"repository_owner_id",
40+
"job_workflow_ref",
3341
# preverified claims
3442
"iss",
3543
"iat",
@@ -51,7 +59,7 @@ def test_github_provider_all_known_claims(self):
5159
"event_name",
5260
"ref_type",
5361
"repository_id",
54-
"job_workflow_ref",
62+
"workflow",
5563
}
5664

5765
def test_github_provider_computed_properties(self):
@@ -120,7 +128,7 @@ def test_github_provider_verifies(self, monkeypatch):
120128
workflow_filename="fakeworkflow.yml",
121129
)
122130

123-
noop_check = pretend.call_recorder(lambda l, r: True)
131+
noop_check = pretend.call_recorder(lambda gt, sc, ac: True)
124132
verifiable_claims = {
125133
claim_name: noop_check for claim_name in provider.__verifiable_claims__
126134
}
@@ -132,3 +140,62 @@ def test_github_provider_verifies(self, monkeypatch):
132140
}
133141
assert provider.verify_claims(signed_claims=signed_claims)
134142
assert len(noop_check.calls) == len(verifiable_claims)
143+
144+
@pytest.mark.parametrize(
145+
("claim", "ref", "valid"),
146+
[
147+
# okay: workflow name, followed by a nonempty ref
148+
(
149+
"foo/bar/.github/workflows/baz.yml@refs/tags/v0.0.1",
150+
"refs/tags/v0.0.1",
151+
True,
152+
),
153+
("foo/bar/.github/workflows/baz.yml@refs/pulls/6", "refs/pulls/6", True),
154+
(
155+
"foo/bar/.github/workflows/baz.yml@refs/heads/main",
156+
"refs/heads/main",
157+
True,
158+
),
159+
(
160+
"foo/bar/.github/workflows/baz.yml@notrailingslash",
161+
"notrailingslash",
162+
True,
163+
),
164+
# bad: workflow name, empty or missing ref
165+
("foo/bar/.github/workflows/baz.yml@emptyref", "", False),
166+
("foo/bar/.github/workflows/baz.yml@missingref", None, False),
167+
# bad: workflow name with various attempted impersonations
168+
(
169+
"foo/bar/.github/workflows/baz.yml@fake.yml@notrailingslash",
170+
"notrailingslash",
171+
False,
172+
),
173+
(
174+
"foo/bar/.github/workflows/baz.yml@fake.yml@refs/pulls/6",
175+
"refs/pulls/6",
176+
False,
177+
),
178+
# bad: missing tail or workflow name or otherwise partial
179+
("foo/bar/.github/workflows/baz.yml@", "notrailingslash", False),
180+
("foo/bar/.github/workflows/@", "notrailingslash", False),
181+
("foo/bar/.github/workflows/", "notrailingslash", False),
182+
("baz.yml", "notrailingslash", False),
183+
(
184+
"foo/bar/.github/workflows/baz.yml@malicious.yml@",
185+
"notrailingslash",
186+
False,
187+
),
188+
("foo/bar/.github/workflows/baz.yml@@", "notrailingslash", False),
189+
("", "notrailingslash", False),
190+
],
191+
)
192+
def test_github_provider_job_workflow_ref(self, claim, ref, valid):
193+
provider = models.GitHubProvider(
194+
repository_name="bar",
195+
repository_owner="foo",
196+
repository_owner_id=pretend.stub(),
197+
workflow_filename="baz.yml",
198+
)
199+
200+
check = models.GitHubProvider.__verifiable_claims__["job_workflow_ref"]
201+
assert check(provider.job_workflow_ref, claim, {"ref": ref}) is valid

warehouse/oidc/models.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,38 @@
2222
from warehouse.packaging.models import Project
2323

2424

25+
def _check_claim_binary(binary_func):
26+
"""
27+
Wraps a binary comparison function so that it takes three arguments instead,
28+
ignoring the third.
29+
30+
This is used solely to make claim verification compatible with "trivial"
31+
checks like `str.__eq__`.
32+
"""
33+
34+
def wrapper(ground_truth, signed_claim, all_signed_claims):
35+
return binary_func(ground_truth, signed_claim)
36+
37+
return wrapper
38+
39+
40+
def _check_job_workflow_ref(ground_truth, signed_claim, all_signed_claims):
41+
# We expect a string formatted as follows:
42+
# OWNER/REPO/.github/workflows/WORKFLOW.yml@REF
43+
# where REF is the value of the `ref` claim.
44+
45+
# Defensive: GitHub should never give us an empty job_workflow_ref,
46+
# but we check for one anyways just in case.
47+
if not signed_claim:
48+
return False
49+
50+
ref = all_signed_claims.get("ref")
51+
if not ref:
52+
return False
53+
54+
return f"{ground_truth}@{ref}" == signed_claim
55+
56+
2557
class OIDCProviderProjectAssociation(db.Model):
2658
__tablename__ = "oidc_provider_project_association"
2759

@@ -52,8 +84,10 @@ class OIDCProvider(db.Model):
5284
}
5385

5486
# A map of claim names to "check" functions, each of which
55-
# has the signature `check(ground-truth, signed-claim) -> bool`.
56-
__verifiable_claims__: Dict[str, Callable[[Any, Any], bool]] = dict()
87+
# has the signature `check(ground-truth, signed-claim, all-signed-claims) -> bool`.
88+
__verifiable_claims__: Dict[
89+
str, Callable[[Any, Any, Dict[str, Any]], bool]
90+
] = dict()
5791

5892
# Claims that have already been verified during the JWT signature
5993
# verification phase.
@@ -115,7 +149,7 @@ def verify_claims(self, signed_claims):
115149
)
116150
return False
117151

118-
if not check(getattr(self, claim_name), signed_claim):
152+
if not check(getattr(self, claim_name), signed_claim, signed_claims):
119153
return False
120154

121155
return True
@@ -145,10 +179,10 @@ class GitHubProvider(OIDCProvider):
145179
workflow_filename = Column(String)
146180

147181
__verifiable_claims__ = {
148-
"repository": str.__eq__,
149-
"workflow": str.__eq__,
150-
"repository_owner": str.__eq__,
151-
"repository_owner_id": str.__eq__,
182+
"repository": _check_claim_binary(str.__eq__),
183+
"repository_owner": _check_claim_binary(str.__eq__),
184+
"repository_owner_id": _check_claim_binary(str.__eq__),
185+
"job_workflow_ref": _check_job_workflow_ref,
152186
}
153187

154188
__unchecked_claims__ = {
@@ -166,8 +200,7 @@ class GitHubProvider(OIDCProvider):
166200
"event_name",
167201
"ref_type",
168202
"repository_id",
169-
# TODO(#11096): Support reusable workflows.
170-
"job_workflow_ref",
203+
"workflow",
171204
}
172205

173206
@property
@@ -179,8 +212,8 @@ def repository(self):
179212
return f"{self.repository_owner}/{self.repository_name}"
180213

181214
@property
182-
def workflow(self):
183-
return self.workflow_filename
215+
def job_workflow_ref(self):
216+
return f"{self.repository}/.github/workflows/{self.workflow_filename}"
184217

185218
def __str__(self):
186219
return f"{self.workflow_filename} @ {self.repository}"

0 commit comments

Comments
 (0)