Skip to content

Commit 9d559e1

Browse files
facutuescawoodruffwdi
authored
Verify github.io URLs with Trusted Publishing (#16499)
* Verify github.io URLs with Trusted Publishing * Update tests/unit/oidc/models/test_github.py Co-authored-by: William Woodruff <[email protected]> --------- Co-authored-by: William Woodruff <[email protected]> Co-authored-by: Dustin Ingram <[email protected]>
1 parent 28639bb commit 9d559e1

File tree

6 files changed

+191
-125
lines changed

6 files changed

+191
-125
lines changed

tests/unit/forklift/test_legacy.py

Lines changed: 7 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -4799,84 +4799,6 @@ def test_missing_trailing_slash_redirect(pyramid_request):
47994799
assert resp.headers["Location"] == "/legacy/"
48004800

48014801

4802-
@pytest.mark.parametrize(
4803-
("url", "publisher_url", "expected"),
4804-
[
4805-
( # GitHub trivial case
4806-
"https://github.com/owner/project",
4807-
"https://github.com/owner/project",
4808-
True,
4809-
),
4810-
( # ActiveState trivial case
4811-
"https://platform.activestate.com/owner/project",
4812-
"https://platform.activestate.com/owner/project",
4813-
True,
4814-
),
4815-
( # GitLab trivial case
4816-
"https://gitlab.com/owner/project",
4817-
"https://gitlab.com/owner/project",
4818-
True,
4819-
),
4820-
( # URL is a sub-path of the TP URL
4821-
"https://github.com/owner/project/issues",
4822-
"https://github.com/owner/project",
4823-
True,
4824-
),
4825-
( # Normalization
4826-
"https://GiThUB.com/owner/project/",
4827-
"https://github.com/owner/project",
4828-
True,
4829-
),
4830-
( # TP URL is a prefix, but not a parent of the URL
4831-
"https://github.com/owner/project22",
4832-
"https://github.com/owner/project",
4833-
False,
4834-
),
4835-
( # URL is a parent of the TP URL
4836-
"https://github.com/owner",
4837-
"https://github.com/owner/project",
4838-
False,
4839-
),
4840-
( # Scheme component does not match
4841-
"http://github.com/owner/project",
4842-
"https://github.com/owner/project",
4843-
False,
4844-
),
4845-
( # Host component does not match
4846-
"https://gitlab.com/owner/project",
4847-
"https://github.com/owner/project",
4848-
False,
4849-
),
4850-
( # Host component matches, but contains user and port info
4851-
"https://[email protected]:443/owner/project",
4852-
"https://github.com/owner/project",
4853-
False,
4854-
),
4855-
( # URL path component is empty
4856-
"https://github.com",
4857-
"https://github.com/owner/project",
4858-
False,
4859-
),
4860-
( # TP URL path component is empty
4861-
# (currently no TPs have an empty path, so even if the given URL is a
4862-
# sub-path of the TP URL, we fail the verification)
4863-
"https://github.com/owner/project",
4864-
"https://github.com",
4865-
False,
4866-
),
4867-
( # Both path components are empty
4868-
# (currently no TPs have an empty path, so even if the given URL is the
4869-
# same as the TP URL, we fail the verification)
4870-
"https://github.com",
4871-
"https://github.com",
4872-
False,
4873-
),
4874-
],
4875-
)
4876-
def test_verify_url_with_trusted_publisher(url, publisher_url, expected):
4877-
assert legacy._verify_url_with_trusted_publisher(url, publisher_url) == expected
4878-
4879-
48804802
@pytest.mark.parametrize(
48814803
("url", "project_name", "project_normalized_name", "expected"),
48824804
[
@@ -4992,24 +4914,27 @@ def test_verify_url_pypi(url, project_name, project_normalized_name, expected):
49924914

49934915
def test_verify_url():
49944916
# `_verify_url` is just a helper function that calls `_verify_url_pypi` and
4995-
# `_verify_url_with_trusted_publisher`, where the actual verification logic lives.
4917+
# `OIDCPublisher.verify_url`, where the actual verification logic lives.
4918+
publisher_verifies = pretend.stub(verify_url=lambda url: True)
4919+
publisher_fails = pretend.stub(verify_url=lambda url: False)
4920+
49964921
assert legacy._verify_url(
49974922
url="https://pypi.org/project/myproject/",
4998-
publisher_url=None,
4923+
publisher=None,
49994924
project_name="myproject",
50004925
project_normalized_name="myproject",
50014926
)
50024927

50034928
assert legacy._verify_url(
50044929
url="https://github.com/org/myproject/issues",
5005-
publisher_url="https://github.com/org/myproject",
4930+
publisher=publisher_verifies,
50064931
project_name="myproject",
50074932
project_normalized_name="myproject",
50084933
)
50094934

50104935
assert not legacy._verify_url(
50114936
url="example.com",
5012-
publisher_url="https://github.com/or/myproject",
4937+
publisher=publisher_fails,
50134938
project_name="myproject",
50144939
project_normalized_name="myproject",
50154940
)

tests/unit/oidc/models/test_core.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,89 @@ def test_oidc_publisher_not_default_verifiable(self):
5454
publisher.verify_claims(signed_claims={}, publisher_service=pretend.stub())
5555
assert str(e.value) == "No required verifiable claims"
5656

57+
@pytest.mark.parametrize(
58+
("url", "publisher_url", "expected"),
59+
[
60+
( # GitHub trivial case
61+
"https://github.com/owner/project",
62+
"https://github.com/owner/project",
63+
True,
64+
),
65+
( # ActiveState trivial case
66+
"https://platform.activestate.com/owner/project",
67+
"https://platform.activestate.com/owner/project",
68+
True,
69+
),
70+
( # GitLab trivial case
71+
"https://gitlab.com/owner/project",
72+
"https://gitlab.com/owner/project",
73+
True,
74+
),
75+
( # URL is a sub-path of the TP URL
76+
"https://github.com/owner/project/issues",
77+
"https://github.com/owner/project",
78+
True,
79+
),
80+
( # Normalization
81+
"https://GiThUB.com/owner/project/",
82+
"https://github.com/owner/project",
83+
True,
84+
),
85+
( # TP URL is a prefix, but not a parent of the URL
86+
"https://github.com/owner/project22",
87+
"https://github.com/owner/project",
88+
False,
89+
),
90+
( # URL is a parent of the TP URL
91+
"https://github.com/owner",
92+
"https://github.com/owner/project",
93+
False,
94+
),
95+
( # Scheme component does not match
96+
"http://github.com/owner/project",
97+
"https://github.com/owner/project",
98+
False,
99+
),
100+
( # Host component does not match
101+
"https://gitlab.com/owner/project",
102+
"https://github.com/owner/project",
103+
False,
104+
),
105+
( # Host component matches, but contains user and port info
106+
"https://[email protected]:443/owner/project",
107+
"https://github.com/owner/project",
108+
False,
109+
),
110+
( # URL path component is empty
111+
"https://github.com",
112+
"https://github.com/owner/project",
113+
False,
114+
),
115+
( # TP URL path component is empty
116+
# (currently no TPs have an empty path, so even if the given URL is a
117+
# sub-path of the TP URL, we fail the verification)
118+
"https://github.com/owner/project",
119+
"https://github.com",
120+
False,
121+
),
122+
( # Both path components are empty
123+
# (currently no TPs have an empty path, so even if the given URL is the
124+
# same as the TP URL, we fail the verification)
125+
"https://github.com",
126+
"https://github.com",
127+
False,
128+
),
129+
],
130+
)
131+
def test_verify_url(self, monkeypatch, url, publisher_url, expected):
132+
class ConcretePublisher(_core.OIDCPublisher):
133+
@property
134+
def publisher_base_url(self):
135+
return publisher_url
136+
137+
publisher = ConcretePublisher()
138+
assert publisher.verify_url(url) == expected
139+
57140

58141
def test_check_existing_jti():
59142
publisher = pretend.stub(

tests/unit/oidc/models/test_github.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,27 @@ def test_github_publisher_duplicates_cant_be_created(self, db_request):
656656
db_request.db.add(publisher2)
657657
db_request.db.commit()
658658

659+
@pytest.mark.parametrize(
660+
("url", "expected"),
661+
[
662+
("https://repository_owner.github.io/repository_name/", True),
663+
("https://repository_owner.github.io/repository_name", True),
664+
("https://repository_owner.github.io/repository_name/subpage", True),
665+
("https://repository_owner.github.io/repository_name/../malicious", False),
666+
("https://repository_owner.github.io/", False),
667+
("https://repository_owner.github.io/unrelated_name/", False),
668+
],
669+
)
670+
def test_github_publisher_verify_url(self, url, expected):
671+
publisher = github.GitHubPublisher(
672+
repository_name="repository_name",
673+
repository_owner="repository_owner",
674+
repository_owner_id="666",
675+
workflow_filename="workflow_filename.yml",
676+
environment="",
677+
)
678+
assert publisher.verify_url(url) == expected
679+
659680

660681
class TestPendingGitHubPublisher:
661682
def test_reify_does_not_exist_yet(self, db_request):

warehouse/forklift/legacy.py

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
from warehouse.forklift.forms import UploadForm, _filetype_extension_mapping
6363
from warehouse.macaroons.models import Macaroon
6464
from warehouse.metrics import IMetricsService
65+
from warehouse.oidc.models import OIDCPublisher
6566
from warehouse.packaging.interfaces import IFileStorage, IProjectService
6667
from warehouse.packaging.models import (
6768
Dependency,
@@ -481,44 +482,11 @@ def _verify_url_pypi(url: str, project_name: str, project_normalized_name: str)
481482
)
482483

483484

484-
def _verify_url_with_trusted_publisher(url: str, publisher_url: str) -> bool:
485-
"""
486-
Verify a given URL against a Trusted Publisher URL
487-
488-
A URL is considered "verified" iff it matches the Trusted Publisher URL
489-
such that, when both URLs are normalized:
490-
- The scheme component is the same (e.g: both use `https`)
491-
- The authority component is the same (e.g.: `github.com`)
492-
- The path component is the same, or a sub-path of the Trusted Publisher URL
493-
(e.g.: `org/project` and `org/project/issues.html` will pass verification
494-
against an `org/project` Trusted Publisher path component)
495-
- The path component of the Trusted Publisher URL is not empty
496-
Note: We compare the authority component instead of the host component because
497-
the authority includes the host, and in practice neither URL should have user
498-
nor port information.
499-
"""
500-
publisher_uri = rfc3986.api.uri_reference(publisher_url).normalize()
501-
user_uri = rfc3986.api.uri_reference(url).normalize()
502-
if publisher_uri.path is None:
503-
# Currently no Trusted Publishers have an empty path component,
504-
# so we defensively fail verification.
505-
return False
506-
elif user_uri.path and publisher_uri.path:
507-
is_subpath = publisher_uri.path == user_uri.path or user_uri.path.startswith(
508-
publisher_uri.path + "/"
509-
)
510-
else:
511-
is_subpath = publisher_uri.path == user_uri.path
512-
513-
return (
514-
publisher_uri.scheme == user_uri.scheme
515-
and publisher_uri.authority == user_uri.authority
516-
and is_subpath
517-
)
518-
519-
520485
def _verify_url(
521-
url: str, publisher_url: str | None, project_name: str, project_normalized_name: str
486+
url: str,
487+
publisher: OIDCPublisher | None,
488+
project_name: str,
489+
project_normalized_name: str,
522490
) -> bool:
523491
if _verify_url_pypi(
524492
url=url,
@@ -527,10 +495,10 @@ def _verify_url(
527495
):
528496
return True
529497

530-
if not publisher_url:
498+
if not publisher:
531499
return False
532500

533-
return _verify_url_with_trusted_publisher(url=url, publisher_url=publisher_url)
501+
return publisher.verify_url(url)
534502

535503

536504
def _sort_releases(request: Request, project: Project):
@@ -894,9 +862,6 @@ def file_upload(request):
894862
) from None
895863

896864
# Verify any verifiable URLs
897-
publisher_base_url = (
898-
request.oidc_publisher.publisher_base_url if request.oidc_publisher else None
899-
)
900865
project_urls = (
901866
{}
902867
if not meta.project_urls
@@ -905,7 +870,7 @@ def file_upload(request):
905870
"url": url,
906871
"verified": _verify_url(
907872
url=url,
908-
publisher_url=publisher_base_url,
873+
publisher=request.oidc_publisher,
909874
project_name=project.name,
910875
project_normalized_name=project.normalized_name,
911876
),

warehouse/oidc/models/_core.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from collections.abc import Callable
1616
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, Unpack
1717

18+
import rfc3986
1819
import sentry_sdk
1920

2021
from sigstore.verify.policy import VerificationPolicy
@@ -325,6 +326,42 @@ def stored_claims(
325326
# Only concrete subclasses are constructed.
326327
raise NotImplementedError
327328

329+
def verify_url(self, url: str) -> bool:
330+
"""
331+
Verify a given URL against this Trusted Publisher's base URL
332+
333+
A URL is considered "verified" iff it matches the Trusted Publisher URL
334+
such that, when both URLs are normalized:
335+
- The scheme component is the same (e.g: both use `https`)
336+
- The authority component is the same (e.g.: `github.com`)
337+
- The path component is the same, or a sub-path of the Trusted Publisher URL
338+
(e.g.: `org/project` and `org/project/issues.html` will pass verification
339+
against an `org/project` Trusted Publisher path component)
340+
- The path component of the Trusted Publisher URL is not empty
341+
Note: We compare the authority component instead of the host component because
342+
the authority includes the host, and in practice neither URL should have user
343+
nor port information.
344+
"""
345+
publisher_uri = rfc3986.api.uri_reference(self.publisher_base_url).normalize()
346+
user_uri = rfc3986.api.uri_reference(url).normalize()
347+
if publisher_uri.path is None:
348+
# Currently no Trusted Publishers have an empty path component,
349+
# so we defensively fail verification.
350+
return False
351+
elif user_uri.path and publisher_uri.path:
352+
is_subpath = (
353+
publisher_uri.path == user_uri.path
354+
or user_uri.path.startswith(publisher_uri.path + "/")
355+
)
356+
else:
357+
is_subpath = publisher_uri.path == user_uri.path
358+
359+
return (
360+
publisher_uri.scheme == user_uri.scheme
361+
and publisher_uri.authority == user_uri.authority
362+
and is_subpath
363+
)
364+
328365

329366
class OIDCPublisher(OIDCPublisherMixin, db.Model):
330367
__tablename__ = "oidc_publishers"

0 commit comments

Comments
 (0)