Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support verifying URL for Gitlab #3113

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions tests/unit/oidc/models/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from warehouse.oidc import errors
from warehouse.oidc.models import _core
from warehouse.oidc.models._core import verify_url_from_reference


def test_check_claim_binary():
Expand Down Expand Up @@ -179,3 +180,25 @@ def test_check_existing_jti_fails(metrics):
pretend.call("warehouse.oidc.reused_token", tags=["publisher:fakepublisher"])
in metrics.increment.calls
)


@pytest.mark.parametrize(
("reference", "url", "expected"),
[
("https://example.com", "https://example.com", True),
("https://example.com", "https://example.com/", True),
("https://example.com", "https://example.com/subpage", True),
("https://example.com/", "https://example.com/", True),
("https://example.com/", "https://example.com/subpage", True),
# Mismatch between schemes
("https://example.com", "http://example.com", False),
# Wrong authority
("https://example.com", "https://not_example.com", False),
# Missing sub path
("https://example.com/", "https://example.com", False),
# Not sub path
("https://example.com/path1/", "https://example.com/path1/../malicious", False),
],
)
def test_verify_url_from_reference(reference: str, url: str, expected: bool):
assert verify_url_from_reference(reference, url) == expected
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved
98 changes: 91 additions & 7 deletions tests/unit/oidc/models/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from warehouse.oidc import errors
from warehouse.oidc.models import _core, gitlab

PROJECT_NAME = "project_name"
NAMESPACE = "project_owner"


@pytest.mark.parametrize(
("ci_config_ref_uri", "expected"),
Expand Down Expand Up @@ -603,17 +606,98 @@ def test_gitlab_publisher_duplicates_cant_be_created(self, db_request):
db_request.db.commit()

@pytest.mark.parametrize(
("url", "expected"),
("project_name", "namespace", "url", "expected"),
[
("https://gitlab.com/repository_owner/repository_name.git", True),
("https://gitlab.com/repository_owner/repository_name.git/", True),
("https://gitlab.com/repository_owner/repository_name.git/issues", False),
(
PROJECT_NAME,
NAMESPACE,
f"https://gitlab.com/{NAMESPACE}/{PROJECT_NAME}.git",
True,
),
(
PROJECT_NAME,
NAMESPACE,
f"https://gitlab.com/{NAMESPACE}/{PROJECT_NAME}.git/",
True,
),
(
PROJECT_NAME,
NAMESPACE,
f"https://gitlab.com/{NAMESPACE}/{PROJECT_NAME}.git/issues",
False,
),
(
PROJECT_NAME,
NAMESPACE,
f"https://{NAMESPACE}.gitlab.io/{PROJECT_NAME}/",
True,
),
(
PROJECT_NAME,
NAMESPACE,
f"https://{NAMESPACE}.gitlab.io/{PROJECT_NAME}/subpage/",
True,
),
(
PROJECT_NAME,
"owner.with.dot",
f"https://owner.with.dot.gitlab.io/{PROJECT_NAME}",
True,
),
( # Unique domains are not supported
PROJECT_NAME,
NAMESPACE,
f"https://{PROJECT_NAME}-123456.gitlab.io/",
False,
),
# Project name is not properly formed
(PROJECT_NAME, NAMESPACE, f"https://{NAMESPACE}.gitlab.io/", False),
(
f"{NAMESPACE}.gitlab.io",
NAMESPACE,
f"https://{NAMESPACE}.gitlab.io",
True,
),
(
f"{NAMESPACE}.gitlab.io",
NAMESPACE,
f"https://{NAMESPACE}.gitlab.io/",
True,
),
(
f"{NAMESPACE}.gitlab.io",
NAMESPACE,
f"https://{NAMESPACE}.gitlab.io/subpage",
True,
),
( # Only for user/group own pages
"project_name.gitlab.io",
NAMESPACE,
f"https://{NAMESPACE}.gitlab.io/subpage",
False,
),
(
"project",
"group/subgroup",
"https://group.gitlab.io/subgroup/project/",
True,
),
(
"project",
"group/subgroup",
"https://group.gitlab.io/subgroup/project/about",
True,
),
# The namespace should only contain 1 element
("group.gitlab.io", "group/subgroup", "https://group.gitlab.io/", False),
],
)
def test_gitlab_publisher_verify_url(self, url, expected):
def test_gitlab_publisher_verify_url(
self, project_name: str, namespace: str, url: str, expected: bool
):
publisher = gitlab.GitLabPublisher(
project="repository_name",
namespace="repository_owner",
project=project_name,
namespace=namespace,
workflow_filepath="workflow_filename.yml",
environment="",
)
Expand Down
33 changes: 33 additions & 0 deletions warehouse/oidc/models/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ def check_existing_jti(
return True


def verify_url_from_reference(reference_url: str, url: str) -> bool:
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved
"""
Verify a given URL against a reference URL.

This method checks that both URLs have:
- the same scheme
- the same authority

Finally, that the URL is a sub-path of the reference
"""
reference_uri = rfc3986.api.uri_reference(reference_url).normalize()
user_uri = rfc3986.api.uri_reference(url).normalize()

if not user_uri.path and reference_uri.path:
return False

# A reference path can be a prefix of a user path only if it ends
# with a forward slash ("/"). E.g: `my/path` is a prefix of both
# `/my/path/user` and `my/path_user`, but only the first one should
# pass verification.
reference_path_prefix = (
"" if reference_uri.path is None else reference_uri.path.rstrip("/") + "/"
)
is_subpath = reference_uri.path == user_uri.path or user_uri.path.startswith(
reference_path_prefix
)
return (
reference_uri.scheme == user_uri.scheme
and reference_uri.authority == user_uri.authority
and is_subpath
)


class OIDCPublisherProjectAssociation(db.Model):
__tablename__ = "oidc_publisher_project_association"

Expand Down
18 changes: 2 additions & 16 deletions warehouse/oidc/models/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@

from typing import Any

import rfc3986

from sigstore.verify.policy import (
AllOf,
AnyOf,
Expand All @@ -34,6 +32,7 @@
PendingOIDCPublisher,
check_claim_binary,
check_existing_jti,
verify_url_from_reference,
)

# This expression matches the workflow filename component of a GitHub
Expand Down Expand Up @@ -363,20 +362,7 @@ def verify_url(self, url: str):
return True

docs_url = f"https://{self.repository_owner}.github.io/{self.repository_name}"
docs_uri = rfc3986.api.uri_reference(docs_url).normalize()
user_uri = rfc3986.api.uri_reference(url).normalize()

if not user_uri.path:
return False

is_subpath = docs_uri.path == user_uri.path or user_uri.path.startswith(
docs_uri.path + "/"
)
return (
docs_uri.scheme == user_uri.scheme
and docs_uri.authority == user_uri.authority
and is_subpath
)
return verify_url_from_reference(docs_url, url)
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved


class PendingGitHubPublisher(GitHubPublisherMixin, PendingOIDCPublisher):
Expand Down
38 changes: 37 additions & 1 deletion warehouse/oidc/models/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
OIDCPublisher,
PendingOIDCPublisher,
check_existing_jti,
verify_url_from_reference,
)

# This expression matches the workflow filepath component of a GitLab
Expand Down Expand Up @@ -289,9 +290,44 @@ def verify_url(self, url: str):
in repo URLs, since `gitlab.com/org/repo.git` always redirects to
`gitlab.com/org/repo`. This does not apply to subpaths like
`gitlab.com/org/repo.git/issues`, which do not redirect to the correct URL.

In addition to the generic Trusted Publisher verification logic in
the parent class, the GitLab Trusted Publisher allows URLs hosted
on `gitlab.io` for the configured repository, i.e:
`https://${OWNER}.gitlab.io/${SUBGROUP}/${PROJECT}`.

This method does not support the verification when the Unique Domain setting is
used.

The rules implemented in this method are derived from
https://docs.gitlab.com/ee/user/project/pages/getting_started_part_one.html#project-website-examples
https://docs.gitlab.com/ee/user/project/pages/getting_started_part_one.html#user-and-group-website-examples

The table stems from GitLab documentation and is replicated here for clarity.
| Namespace | GitLab Page URL |
| ---------------------------- | ---------------------------------------- |
| username/username.example.io | https://username.gitlab.io |
| acmecorp/acmecorp.example.io | https://acmecorp.gitlab.io |
| username/my-website | https://username.gitlab.io/my-website |
| group/webshop | https://group.gitlab.io/webshop |
| group/subgroup/project | https://group.gitlab.io/subgroup/project |
"""
url_for_generic_check = url.removesuffix("/").removesuffix(".git")
return super().verify_url(url_for_generic_check)
if super().verify_url(url_for_generic_check):
return True

try:
owner, subgroup = self.namespace.split("/", maxsplit=1)
facutuesca marked this conversation as resolved.
Show resolved Hide resolved
subgroup += "/"
except ValueError:
owner, subgroup = self.namespace, ""

if self.project == f"{owner}.gitlab.io" and not subgroup:
docs_url = f"https://{owner}.gitlab.io"
else:
docs_url = f"https://{owner}.gitlab.io/{subgroup}{self.project}"

return verify_url_from_reference(docs_url, url)
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved


class PendingGitLabPublisher(GitLabPublisherMixin, PendingOIDCPublisher):
Expand Down