Skip to content

Commit

Permalink
config: allow bool values for repo cert
Browse files Browse the repository at this point in the history
This change allows certificates.<repo>.cert configuration to accept
boolean values in addition to certificate paths. This allows for
repositories to skip TLS certificate validation for cases where
self-signed certificats are used by package sources.

In addition to the above, the certificate configuration handling has
now been delegated to a dedicated dataclass.

Co-authored-by: Celeborn2BeAlive <laurent.noel.c2ba@gmail.com>
Co-authored-by: Maayan Bar <maayanbar13@gmail.com>
  • Loading branch information
3 people authored and neersighted committed May 29, 2022
1 parent 6a6034e commit 8cb3aab
Show file tree
Hide file tree
Showing 14 changed files with 196 additions and 107 deletions.
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,15 @@ for more information.

### `certificates.<name>.cert`:

**Type**: string
**Type**: string | bool

Set custom certificate authority for repository `<name>`.
See [Repositories - Configuring credentials - Custom certificate authority]({{< relref "repositories#custom-certificate-authority-and-mutual-tls-authentication" >}})
for more information.

This configuration can be set to `false`, if TLS certificate verification should be skipped for this
repository.

### `certificates.<name>.client-cert`:

**Type**: string
Expand Down
15 changes: 15 additions & 0 deletions docs/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,21 @@ poetry config certificates.foo.cert /path/to/ca.pem
poetry config certificates.foo.client-cert /path/to/client.pem
```

{{% note %}}
The value of `certificates.<repository>.cert` can be set to `false` if certificate verification is
required to be skipped. This is useful for cases where a package source with self-signed certificates
are used.

```bash
poetry config certificates.foo.cert false
```

{{% warning %}}
Disabling certificate verification is not recommended as it is does not conform to security
best practices.
{{% /warning %}}
{{% /note %}}

## Caches

Poetry employs multiple caches for package sources in order to improve user experience and avoid duplicate network
Expand Down
28 changes: 16 additions & 12 deletions src/poetry/console/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import re

from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import cast
Expand All @@ -11,7 +12,11 @@
from cleo.helpers import option

from poetry.config.config import PackageFilterPolicy
from poetry.config.config import boolean_normalizer
from poetry.config.config import boolean_validator
from poetry.config.config import int_normalizer
from poetry.console.commands.command import Command
from poetry.locations import DEFAULT_CACHE_DIR


if TYPE_CHECKING:
Expand Down Expand Up @@ -48,13 +53,6 @@ class ConfigCommand(Command):

@property
def unique_config_values(self) -> dict[str, tuple[Any, Any, Any]]:
from pathlib import Path

from poetry.config.config import boolean_normalizer
from poetry.config.config import boolean_validator
from poetry.config.config import int_normalizer
from poetry.locations import DEFAULT_CACHE_DIR

unique_config_values = {
"cache-dir": (
str,
Expand Down Expand Up @@ -275,20 +273,26 @@ def handle(self) -> int | None:
return 0

# handle certs
m = re.match(
r"(?:certificates)\.([^.]+)\.(cert|client-cert)", self.argument("key")
)
m = re.match(r"certificates\.([^.]+)\.(cert|client-cert)", self.argument("key"))
if m:
repository = m.group(1)
key = m.group(2)

if self.option("unset"):
config.auth_config_source.remove_property(
f"certificates.{m.group(1)}.{m.group(2)}"
f"certificates.{repository}.{key}"
)

return 0

if len(values) == 1:
new_value: str | bool = values[0]

if key == "cert" and boolean_validator(values[0]):
new_value = boolean_normalizer(values[0])

config.auth_config_source.add_property(
f"certificates.{m.group(1)}.{m.group(2)}", values[0]
f"certificates.{repository}.{key}", new_value
)
else:
raise ValueError("You must pass exactly 1 value")
Expand Down
14 changes: 10 additions & 4 deletions src/poetry/installation/pip_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,17 @@ def install(self, package: Package, update: bool = False) -> None:
args += ["--trusted-host", parsed.hostname]

if isinstance(repository, HTTPRepository):
if repository.cert:
args += ["--cert", str(repository.cert)]
certificates = repository.certificates

if repository.client_cert:
args += ["--client-cert", str(repository.client_cert)]
if certificates.cert:
args += ["--cert", str(certificates.cert)]

if parsed.scheme == "https" and not certificates.verify:
assert parsed.hostname is not None
args += ["--trusted-host", parsed.hostname]

if certificates.client_cert:
args += ["--client-cert", str(certificates.client_cert)]

index_url = repository.authenticated_url

Expand Down
11 changes: 5 additions & 6 deletions src/poetry/publishing/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

from poetry.publishing.uploader import Uploader
from poetry.utils.authenticator import Authenticator
from poetry.utils.helpers import get_cert
from poetry.utils.helpers import get_client_cert


if TYPE_CHECKING:
Expand Down Expand Up @@ -72,9 +70,10 @@ def publish(
username = auth.username
password = auth.password

resolved_client_cert = client_cert or get_client_cert(
self._poetry.config, repository_name
)
certificates = self._authenticator.get_certs_for_repository(repository_name)
resolved_cert = cert or certificates.cert or certificates.verify
resolved_client_cert = client_cert or certificates.client_cert

# Requesting missing credentials but only if there is not a client cert defined.
if not resolved_client_cert and hasattr(self._io, "ask"):
if username is None:
Expand All @@ -96,7 +95,7 @@ def publish(

self._uploader.upload(
url,
cert=cert or get_cert(self._poetry.config, repository_name),
cert=resolved_cert,
client_cert=resolved_client_cert,
dry_run=dry_run,
skip_existing=skip_existing,
Expand Down
8 changes: 3 additions & 5 deletions src/poetry/publishing/uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import hashlib
import io

from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any

Expand All @@ -25,8 +26,6 @@


if TYPE_CHECKING:
from pathlib import Path

from cleo.io.null_io import NullIO

from poetry.poetry import Poetry
Expand Down Expand Up @@ -114,15 +113,14 @@ def get_auth(self) -> tuple[str, str] | None:
def upload(
self,
url: str,
cert: Path | None = None,
cert: Path | bool = True,
client_cert: Path | None = None,
dry_run: bool = False,
skip_existing: bool = False,
) -> None:
session = self.make_session()

if cert:
session.verify = str(cert)
session.verify = str(cert) if isinstance(cert, Path) else cert

if client_cert:
session.cert = str(client_cert)
Expand Down
15 changes: 3 additions & 12 deletions src/poetry/repositories/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
if TYPE_CHECKING:
from poetry.config.config import Config
from poetry.inspection.info import PackageInfo
from poetry.utils.authenticator import RepositoryCertificateConfig


class HTTPRepository(CachedRepository, ABC):
Expand Down Expand Up @@ -59,18 +60,8 @@ def url(self) -> str:
return self._url

@property
def cert(self) -> Path | None:
cert = self._authenticator.get_certs_for_url(self.url).get("verify")
if cert:
return Path(cert)
return None

@property
def client_cert(self) -> Path | None:
cert = self._authenticator.get_certs_for_url(self.url).get("cert")
if cert:
return Path(cert)
return None
def certificates(self) -> RepositoryCertificateConfig:
return self._authenticator.get_certs_for_url(self.url)

@property
def authenticated_url(self) -> str:
Expand Down
57 changes: 40 additions & 17 deletions src/poetry/utils/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import urllib.parse

from os.path import commonprefix
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any

Expand All @@ -20,21 +21,42 @@

from poetry.config.config import Config
from poetry.exceptions import PoetryException
from poetry.utils.helpers import get_cert
from poetry.utils.helpers import get_client_cert
from poetry.utils.password_manager import HTTPAuthCredential
from poetry.utils.password_manager import PasswordManager


if TYPE_CHECKING:
from pathlib import Path

from cleo.io.io import IO


logger = logging.getLogger(__name__)


@dataclasses.dataclass(frozen=True)
class RepositoryCertificateConfig:
cert: Path | None = dataclasses.field(default=None)
client_cert: Path | None = dataclasses.field(default=None)
verify: bool = dataclasses.field(default=True)

@classmethod
def create(
cls, repository: str, config: Config | None
) -> RepositoryCertificateConfig:
config = config if config else Config.create()

verify: str | bool = config.get(
f"certificates.{repository}.verify",
config.get(f"certificates.{repository}.cert", True),
)
client_cert: str = config.get(f"certificates.{repository}.client-cert")

return cls(
cert=Path(verify) if isinstance(verify, str) else None,
client_cert=Path(client_cert) if client_cert else None,
verify=verify if isinstance(verify, bool) else True,
)


@dataclasses.dataclass
class AuthenticatorRepositoryConfig:
name: str
Expand All @@ -47,11 +69,8 @@ def __post_init__(self) -> None:
self.netloc = parsed_url.netloc
self.path = parsed_url.path

def certs(self, config: Config) -> dict[str, Path | None]:
return {
"cert": get_client_cert(config, self.name),
"verify": get_cert(config, self.name),
}
def certs(self, config: Config) -> RepositoryCertificateConfig:
return RepositoryCertificateConfig.create(self.name, config)

@property
def http_credential_keys(self) -> list[str]:
Expand Down Expand Up @@ -91,7 +110,7 @@ def __init__(
self._io = io
self._sessions_for_netloc: dict[str, requests.Session] = {}
self._credentials: dict[str, HTTPAuthCredential] = {}
self._certs: dict[str, dict[str, Path | None]] = {}
self._certs: dict[str, RepositoryCertificateConfig] = {}
self._configured_repositories: dict[
str, AuthenticatorRepositoryConfig
] | None = None
Expand Down Expand Up @@ -186,14 +205,13 @@ def request(
stream = kwargs.get("stream")

certs = self.get_certs_for_url(url)
verify = kwargs.get("verify") or certs.get("verify")
cert = kwargs.get("cert") or certs.get("cert")
verify = kwargs.get("verify") or certs.cert or certs.verify
cert = kwargs.get("cert") or certs.client_cert

if cert is not None:
cert = str(cert)

if verify is not None:
verify = str(verify)
verify = str(verify) if isinstance(verify, Path) else verify

settings = session.merge_environment_settings( # type: ignore[no-untyped-call]
prepared_request.url, proxies, stream, verify, cert
Expand Down Expand Up @@ -332,6 +350,11 @@ def get_http_auth(
repository=repository, username=username
)

def get_certs_for_repository(self, name: str) -> RepositoryCertificateConfig:
if name.lower() == "pypi" or name not in self.configured_repositories:
return RepositoryCertificateConfig()
return self.configured_repositories[name].certs(self._config)

@property
def configured_repositories(self) -> dict[str, AuthenticatorRepositoryConfig]:
if self._configured_repositories is None:
Expand All @@ -352,7 +375,7 @@ def add_repository(self, name: str, url: str) -> None:
self.configured_repositories[name] = AuthenticatorRepositoryConfig(name, url)
self.reset_credentials_cache()

def get_certs_for_url(self, url: str) -> dict[str, Path | None]:
def get_certs_for_url(self, url: str) -> RepositoryCertificateConfig:
if url not in self._certs:
self._certs[url] = self._get_certs_for_url(url)
return self._certs[url]
Expand Down Expand Up @@ -398,11 +421,11 @@ def _get_repository_config_for_url(

return candidates[0]

def _get_certs_for_url(self, url: str) -> dict[str, Path | None]:
def _get_certs_for_url(self, url: str) -> RepositoryCertificateConfig:
selected = self.get_repository_config_for_url(url)
if selected:
return selected.certs(config=self._config)
return {"cert": None, "verify": None}
return RepositoryCertificateConfig()


_authenticator: Authenticator | None = None
Expand Down
17 changes: 0 additions & 17 deletions src/poetry/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from poetry.core.packages.package import Package
from requests import Session

from poetry.config.config import Config
from poetry.utils.authenticator import Authenticator


Expand All @@ -33,22 +32,6 @@ def module_name(name: str) -> str:
return canonicalize_name(name).replace(".", "_").replace("-", "_")


def get_cert(config: Config, repository_name: str) -> Path | None:
cert = config.get(f"certificates.{repository_name}.cert")
if cert:
return Path(cert)
else:
return None


def get_client_cert(config: Config, repository_name: str) -> Path | None:
client_cert = config.get(f"certificates.{repository_name}.client-cert")
if client_cert:
return Path(client_cert)
else:
return None


def _on_rm_error(func: Callable[[str], None], path: str, exc_info: Exception) -> None:
if not os.path.exists(path):
return
Expand Down
Loading

0 comments on commit 8cb3aab

Please sign in to comment.