Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 13 additions & 6 deletions src/specify_cli/authentication/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import os
import stat
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
Expand Down Expand Up @@ -48,10 +47,21 @@ def _is_valid_host_pattern(pattern: str) -> bool:
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
such as ``myorg.example.com`` but not ``example.com`` itself
"""
if any(char in pattern for char in "?[]"):
return False
if "*" not in pattern:
return True # exact hostname — already validated as non-empty
# Only *.suffix is allowed; no other wildcard positions
return pattern.startswith("*.") and "*" not in pattern[2:]
return pattern.startswith("*.") and len(pattern) > 2 and "*" not in pattern[2:]


def _host_matches_pattern(hostname: str, pattern: str) -> bool:
"""Match a hostname against an exact host or leading ``*.`` wildcard."""
hostname = hostname.lower()
pattern = pattern.lower()
if pattern.startswith("*.") and _is_valid_host_pattern(pattern):
return hostname.endswith(pattern[1:])
return hostname == pattern


def _norm(value: Any) -> Any:
Expand Down Expand Up @@ -224,8 +234,5 @@ def find_entries_for_url(
return [
e
for e in entries
if any(
pattern == hostname or fnmatch(hostname, pattern)
for pattern in e.hosts
)
if any(_host_matches_pattern(hostname, pattern) for pattern in e.hosts)
]
12 changes: 8 additions & 4 deletions src/specify_cli/authentication/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@

import urllib.error
import urllib.request
from fnmatch import fnmatch
from typing import Callable
from urllib.parse import urlparse

from .._download_security import is_safe_download_redirect
from . import get_provider
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
from .config import (
AuthConfigEntry,
_default_config_path,
_host_matches_pattern,
find_entries_for_url,
load_auth_config,
)


_config_override: list[AuthConfigEntry] | None = None
Expand Down Expand Up @@ -54,8 +59,7 @@ def _load_config() -> list[AuthConfigEntry]:

def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
"""Return True if *hostname* matches any pattern in *hosts*."""
hostname = hostname.lower()
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
return any(_host_matches_pattern(hostname, pattern) for pattern in hosts)


RedirectValidator = Callable[[str, str], None]
Expand Down
80 changes: 80 additions & 0 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,20 @@ def test_multi_wildcard_host_raises(self, tmp_path):
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)

@pytest.mark.parametrize("host", ["gith?b.com", "[a-z].example.com"])
def test_unsupported_glob_metacharacters_raise(self, tmp_path, host):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": [host],
"provider": "github",
"auth": "bearer",
"token_env": "X",
}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)

def test_valid_star_dot_host_accepted(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
Expand Down Expand Up @@ -344,6 +358,39 @@ def test_wildcard_match(self):
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
assert result == [entry]

@pytest.mark.parametrize(
"url",
[
"https://visualstudio.com/project",
"https://evilvisualstudio.com/project",
"https://visualstudio.com.evil.example/project",
],
)
def test_wildcard_does_not_match_apex_or_lookalikes(self, url):
entry = AuthConfigEntry(
hosts=("*.visualstudio.com",),
provider="azure-devops",
auth="basic-pat",
token_env="ADO_PAT",
)
assert find_entries_for_url(url, [entry]) == []

@pytest.mark.parametrize(
("pattern", "url"),
[
("gith?b.com", "https://github.com/org/repo"),
("[a-z].example.com", "https://a.example.com/file"),
],
)
def test_exact_hosts_do_not_apply_glob_semantics(self, pattern, url):
entry = AuthConfigEntry(
hosts=(pattern,),
provider="github",
auth="bearer",
token="sentinel",
)
assert find_entries_for_url(url, [entry]) == []

def test_no_match_returns_empty(self):
entry = _github_entry()
result = find_entries_for_url("https://evil.example.com/file", [entry])
Expand Down Expand Up @@ -1049,6 +1096,39 @@ def test_redirect_outside_hosts_strips_auth(self):
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None

@pytest.mark.parametrize(
("hosts", "target", "expected_auth"),
[
(("*.example.com",), "https://api.example.com/asset", "Bearer tok"),
(("*.example.com",), "https://example.com/asset", None),
(("*.example.com",), "https://evil-example.com/asset", None),
(("gith?b.com",), "https://github.com/asset", None),
(("[a-z].example.com",), "https://a.example.com/asset", None),
],
)
def test_redirect_host_patterns_use_literal_safe_matching(
self, hosts, target, expected_auth
):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io

handler = _StripAuthOnRedirect(hosts)
req = Request(
"https://source.example.org/file",
headers={"Authorization": "Bearer tok"},
)
new_req = handler.redirect_request(
req, io.BytesIO(b""), 302, "Found", {}, target
)

assert new_req is not None
auth = (
new_req.get_header("Authorization")
or new_req.unredirected_hdrs.get("Authorization")
)
assert auth == expected_auth

def test_https_to_http_same_host_redirect_rejected(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
Expand Down