Skip to content

Commit

Permalink
This change allows certificates.<repo>.cert configuration to accept
Browse files Browse the repository at this point in the history
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 committed May 29, 2022
1 parent 6a6034e commit 9c4e870
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
14 changes: 14 additions & 0 deletions docs/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,20 @@ 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 a recommended security practice.
{{% /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 9c4e870

Please sign in to comment.