mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
find_entries_for_url did (urlparse(url).hostname or "").lower() unguarded. a malformed authority (e.g. an unterminated ipv6 bracket "https://[::1") makes urlparse/hostname raise ValueError, so instead of the empty list the function already returns for a host-less url, a raw ValueError leaked out of the shared http client (build_request / open_url call this before any url validation). no auth entry can match such a url, so treat it like the host-less case and return no matches. added a regression test over an unterminated bracket and a bracketed non-ip host; confirmed it fails on the pre-fix code.
218 lines
8.1 KiB
Python
218 lines
8.1 KiB
Python
"""Authentication configuration loader.
|
|
|
|
Reads ``~/.specify/auth.json`` to determine which hosts receive credentials
|
|
and which provider/auth-scheme to use. No credentials are sent without
|
|
an explicit opt-in via this file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
from dataclasses import dataclass
|
|
from fnmatch import fnmatch
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AuthConfigEntry:
|
|
"""A single provider entry from ``auth.json``."""
|
|
|
|
hosts: tuple[str, ...]
|
|
provider: str
|
|
auth: str
|
|
token: str | None = None
|
|
token_env: str | None = None
|
|
# Azure AD service-principal fields
|
|
tenant_id: str | None = None
|
|
client_id: str | None = None
|
|
client_secret_env: str | None = None
|
|
|
|
|
|
def _default_config_path() -> Path:
|
|
"""Return ``~/.specify/auth.json``."""
|
|
return Path.home() / ".specify" / "auth.json"
|
|
|
|
|
|
def _is_valid_host_pattern(pattern: str) -> bool:
|
|
"""Return True for safe host patterns: exact hostnames or ``*.suffix`` only.
|
|
|
|
Rejects patterns like ``*github.com`` (which would match
|
|
``github.com.evil.com``) or multi-wildcard forms. Only these two
|
|
forms are accepted:
|
|
|
|
* ``example.com`` — exact hostname
|
|
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
|
|
such as ``myorg.example.com`` but not ``example.com`` itself
|
|
"""
|
|
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:]
|
|
|
|
|
|
def load_auth_config(
|
|
path: Path | None = None,
|
|
) -> list[AuthConfigEntry]:
|
|
"""Load and validate ``auth.json``, returning configured entries.
|
|
|
|
Returns an empty list when the file does not exist — this means
|
|
all HTTP requests will be unauthenticated (opt-in model).
|
|
|
|
Raises ``ValueError`` on schema violations. Callers that want
|
|
misconfigurations to fail fast can allow this exception to
|
|
propagate; higher-level HTTP helpers may instead catch it,
|
|
warn, and continue with unauthenticated requests.
|
|
"""
|
|
config_path = path or _default_config_path()
|
|
|
|
if not config_path.is_file():
|
|
return []
|
|
|
|
# Warn (but don't fail) if the file is world-readable (POSIX only).
|
|
if os.name != "nt":
|
|
try:
|
|
mode = config_path.stat().st_mode
|
|
if mode & (stat.S_IRGRP | stat.S_IROTH):
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
f"{config_path} is readable by group/others. "
|
|
"Consider restricting with: chmod 600 "
|
|
f"{config_path}",
|
|
UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
except OSError:
|
|
pass # stat failed — skip permission check
|
|
|
|
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}")
|
|
|
|
providers_raw = raw.get("providers")
|
|
if not isinstance(providers_raw, list):
|
|
raise ValueError("auth.json must contain a 'providers' array")
|
|
|
|
entries: list[AuthConfigEntry] = []
|
|
for i, entry_raw in enumerate(providers_raw):
|
|
if not isinstance(entry_raw, dict):
|
|
raise ValueError(f"providers[{i}]: must be a JSON object")
|
|
|
|
hosts = entry_raw.get("hosts")
|
|
if not isinstance(hosts, list) or not hosts:
|
|
raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array")
|
|
if not all(isinstance(h, str) and h.strip() for h in hosts):
|
|
raise ValueError(f"providers[{i}]: each host must be a non-empty string")
|
|
# Normalize hosts: strip whitespace and lowercase
|
|
hosts = [h.strip().lower() for h in hosts]
|
|
# Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com)
|
|
for h in hosts:
|
|
if not _is_valid_host_pattern(h):
|
|
raise ValueError(
|
|
f"providers[{i}]: invalid host pattern {h!r}. "
|
|
"Only exact hostnames or '*.suffix' forms are allowed "
|
|
"(e.g. 'github.com' or '*.visualstudio.com')."
|
|
)
|
|
|
|
provider = entry_raw.get("provider", "")
|
|
if not isinstance(provider, str) or not provider:
|
|
raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string")
|
|
|
|
auth = entry_raw.get("auth", "")
|
|
if not isinstance(auth, str) or not auth:
|
|
raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string")
|
|
|
|
token = entry_raw.get("token")
|
|
token_env = entry_raw.get("token_env")
|
|
|
|
# Validate token/token_env types
|
|
if token is not None and (not isinstance(token, str) or not token.strip()):
|
|
raise ValueError(f"providers[{i}]: 'token' must be a non-empty string")
|
|
if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()):
|
|
raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string")
|
|
|
|
# Validate provider+scheme compatibility
|
|
from . import get_provider as _get_provider
|
|
_prov = _get_provider(provider)
|
|
if _prov is None:
|
|
from . import AUTH_REGISTRY
|
|
raise ValueError(
|
|
f"providers[{i}]: unknown provider {provider!r}; "
|
|
f"registered: {sorted(AUTH_REGISTRY.keys())}"
|
|
)
|
|
if auth not in _prov.supported_auth_schemes:
|
|
raise ValueError(
|
|
f"providers[{i}]: provider {provider!r} does not support "
|
|
f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}"
|
|
)
|
|
|
|
# Validate token source based on auth scheme
|
|
if auth in ("bearer", "basic-pat"):
|
|
if not token and not token_env:
|
|
raise ValueError(
|
|
f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'"
|
|
)
|
|
elif auth == "azure-ad":
|
|
tenant_id = entry_raw.get("tenant_id")
|
|
client_id = entry_raw.get("client_id")
|
|
client_secret_env = entry_raw.get("client_secret_env")
|
|
if not all([tenant_id, client_id, client_secret_env]):
|
|
raise ValueError(
|
|
f"providers[{i}]: auth='azure-ad' requires "
|
|
"'tenant_id', 'client_id', and 'client_secret_env'"
|
|
)
|
|
for field_name, field_val in [
|
|
("tenant_id", tenant_id),
|
|
("client_id", client_id),
|
|
("client_secret_env", client_secret_env),
|
|
]:
|
|
if not isinstance(field_val, str) or not field_val.strip():
|
|
raise ValueError(
|
|
f"providers[{i}]: '{field_name}' must be a non-empty string"
|
|
)
|
|
# azure-cli needs no extra fields
|
|
|
|
entries.append(
|
|
AuthConfigEntry(
|
|
hosts=tuple(hosts),
|
|
provider=provider,
|
|
auth=auth,
|
|
token=token,
|
|
token_env=token_env,
|
|
tenant_id=entry_raw.get("tenant_id"),
|
|
client_id=entry_raw.get("client_id"),
|
|
client_secret_env=entry_raw.get("client_secret_env"),
|
|
)
|
|
)
|
|
|
|
return entries
|
|
|
|
|
|
def find_entries_for_url(
|
|
url: str, entries: list[AuthConfigEntry]
|
|
) -> list[AuthConfigEntry]:
|
|
"""Return entries whose ``hosts`` match the hostname of *url*."""
|
|
# A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1")
|
|
# makes urlparse/hostname raise ValueError. Treat that the same as a
|
|
# host-less URL: no entry can match, so return no matches rather than
|
|
# leaking a raw ValueError out of the shared HTTP client (build_request /
|
|
# open_url call this before any URL validation).
|
|
try:
|
|
hostname = (urlparse(url).hostname or "").lower()
|
|
except ValueError:
|
|
return []
|
|
if not hostname:
|
|
return []
|
|
return [
|
|
e
|
|
for e in entries
|
|
if any(
|
|
pattern == hostname or fnmatch(hostname, pattern)
|
|
for pattern in e.hosts
|
|
)
|
|
]
|