[security][o2ims] Verify Kubernetes API TLS certificates and read the token without a shell - #1170
[security][o2ims] Verify Kubernetes API TLS certificates and read the token without a shell#1170thc1006 wants to merge 2 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @thc1006. Thanks for your PR. I'm waiting for a nephio-project member to verify that this patch is reasonable to test. If it is, they should reply with Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository. |
The o2ims operator passed HTTPS_VERIFY to the verify argument of every
request it makes to the API server, and computed it as
HTTPS_VERIFY = bool(os.getenv("HTTPS_VERIFY", False))
os.getenv returns the default False when the variable is unset and a
string otherwise, so the flag was disabled by default and inverted the
moment anyone tried to use it:
HTTPS_VERIFY unset -> verify=False verification off
HTTPS_VERIFY=false -> verify=True verification on
HTTPS_VERIFY=0 -> verify=True verification on
HTTPS_VERIFY="" -> verify=False verification off
Every string that means "no" enables verification, and the only ways to
disable it are to leave the variable alone or set it to the empty string.
Neither the deployment in tests/ nor the one in nephio-project/catalog
sets it, so both run with verification off against
https://kubernetes.default.svc, while the module suppressed the warning
that would have said so by calling urllib3.disable_warnings() two lines
above. The service account token rides on every one of those requests,
and its ClusterRole can create and patch validatingwebhookconfigurations
and mutatingwebhookconfigurations, so whoever intercepts it can register
a webhook that rewrites objects across the whole cluster.
Compute the verify argument from what Kubernetes already provides: the CA
bundle mounted at /var/run/secrets/kubernetes.io/serviceaccount/ca.crt,
or a bundle named by KUBERNETES_CA_FILE, falling back to the system trust
store. Verification is skipped only when UNSAFE_SKIP_TLS_VERIFY asks for
it by name, and the parser treats anything unrecognised as false so a
typo cannot relax it. HTTPS_VERIFY is no longer read; there is no
sensible reading of its old values to preserve, and startup logs a
warning if it is still set. The blanket disable_warnings() call is gone,
so urllib3 can report an unverified request if one is ever made
deliberately.
The default address becomes the one Kubernetes advertises in
KUBERNETES_SERVICE_HOST rather than http://127.0.0.1:8080, which pointed
at a kubectl proxy whose setup has been commented out in
tests/create-cluster.sh, and sent the bearer token in cleartext to
anything listening on that port. Kubernetes only promises a valid serving
certificate for the advertised address, so tests/deployment/operator.yaml
no longer pins KUBERNETES_BASE_URL to kubernetes.default.svc.
Twenty-two tests cover the truth table above, the CA bundle sources, a
missing bundle, the address selection and the value that reaches
requests. Restoring the old expression fails eleven of them, all of them
cases that should still have been verifying.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
13e1aa4 to
56c1545
Compare
56c1545 to
a6f4b45
Compare
2a94a93 to
7e5d8cc
Compare
The Prow presubmits call the root make unit, make lint and make gosec, and the root Makefile walks only directories that contain a go.mod. The o2ims operator has none, so nothing under operators/o2ims-operator has ever been executed by CI: the tox environments it declares and the tests in tests/test_utils.py run only on a contributor's machine, and a security regression test added there today would not gate anything. Add a presubmit that runs the py312 environment tox already declares, rather than a second copy of the same pip and pytest invocation that would drift from it. It is pinned to python:3.12.10, the interpreter the operator image ships, and runs only when something under operators/o2ims-operator changes. Verified by running the job's exact script in that image: 39 passed in 15.4 seconds, and with a deliberately failing test added the run exits 1, so the job gates rather than merely reports. The job does not mount the build cluster's ServiceAccount token. It runs test code from the pull request and never calls the Kubernetes API, so it has no reason to hold that credential; it also keeps the run hermetic, because the operator reads the standard token path at import time. Its path filter includes .prow.yaml, so a change to the job definition runs the job. Without that, a pull request touching only this file can prove that the YAML parses and nothing else. The image is the one the operator is built from rather than the Debian variant, so the tests exercise the same interpreter, musl and OpenSSL that ship. That matters here: what these tests cover is TLS verification and CA bundle handling. tox is pinned, because an unpinned install makes the gate depend on whichever release happened to be current. Lint is deliberately left out. flake8 reports findings across the package that predate this change, so gating on it would fail every pull request until that debt is paid. py311 is left out as well, since the operator ships on 3.12; adding it is one more entry if the maintainers want the matrix tox describes. Part of nephio-project#848. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> pip and tox are invoked through python -m so the interpreter is the container's rather than whatever happens to be first on PATH, and tox.ini no longer installs pytest-cov: no command asks for coverage, so it was an unpinned dependency the gate did not use. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> The script clears KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT before tox. automountServiceAccountToken: false removes the projected volume, not the service environment the kubelet injects, so a pod with no credentials still looks like a cluster to anything that reads those two variables. The o2ims operator does, at import, to choose a CA bundle. tox does not forward them by default, so this job is green either way today, but that is a property of tox's passenv rather than of anything stated here, and a job that called pytest directly would fail collection. The same isolation is applied inside the test tree in nephio-project#1170, where it belongs; this is the second layer. Keeping the credential mount off is not negotiable either way: the job runs test code from a pull request. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
0b2c92b to
478fef0
Compare
controllers/utils.py resolved the bearer token by interpolating an
environment variable into a shell command:
TOKEN = os.getenv("TOKEN", "/var/run/secrets/.../serviceaccount/token")
TOKEN = os.popen(f"cat {TOKEN}").read()
Whatever TOKEN holds is executed, so a value of the form
"/absent; touch /tmp/pwned" runs the touch. Nothing surfaces either way:
cat writes its complaint to stderr and .read() returns the empty string,
so the operator carries on with an empty bearer token and every request
to the API server answers 401, which callers report as "unauthorized"
with no hint at the cause.
The same two lines fix the token for the lifetime of the process, since
HEADERS_DICT was built from it once at import. Kubernetes rotates the
projected token it mounts, so once the value read at startup expires the
operator gets 401 on every request until someone restarts it.
Swapping os.popen for open() on its own does not settle this, because it
has to answer what happens when the file cannot be read, and both answers
are poor: raising at import stops the operator and the test suite from
starting wherever the file is absent, while swallowing the error
preserves today's silent 401s. Reading the token where it is used answers
the question, since the caller sees the error, and picks up rotation on
the way.
read_token opens the path named by TOKEN and strips the result;
request_headers puts the current value on each request. The strip is not
cosmetic: a token file written with echo ends in a newline, and while
requests accepts such a header value, http.client rejects it on the wire
with "ValueError: Invalid header value", so every request fails.
Six tests cover the shell metacharacters, the unreadable file, the
stripping and the rotation. Restoring the old semantics fails five of
them, and the injected command is observably executed.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The documented refresh for an out-of-cluster token now writes to a
temporary file and renames it into place. Redirecting onto the live path
truncates it before kubectl writes, and the operator opens that file on
every request, so a request landing in the window would read an empty
token. A rename on the same filesystem is atomic.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The TLS fixture now clears the in-pod service variables and the requests
CA bundle overrides as well. It claimed to make the tests behave the same
on a laptop and inside a pod and did not: with KUBERNETES_SERVICE_HOST
set, tls_verify refuses to fall back to the public roots, so eight tests
raised instead of returning a value. With REQUESTS_CA_BUNDLE set, requests
rewrote verify=True to that path before the assertion saw it.
That second one is worth recording beyond the test. requests only applies
those variables when verify is True or None; an explicit bundle path is
never overridden. So the trust anchor is redirectable exactly when this
operator runs outside a pod with no CA configured, and never in a pod,
where the mounted bundle is now required. tls_verify says so.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The endpoint validator also refuses control characters and port zero.
urlsplit and requests disagree about the first: urlsplit drops a tab in
the authority and keeps a NUL, while requests percent-encodes both, so
the host that was checked is not always the host that is connected to.
Measured against requests rather than assumed:
'https://good.example\t.evil' validator saw 'good.example.evil'
requests saw 'good.example%09.evil'
KUBERNETES_BASE_URL is operator configuration rather than attacker input,
so this is not an escalation, but a client that carries a bearer token
should refuse an address it cannot agree on.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The mounted bundle is now applied to the cluster's own API server and to
nothing else. It is reachable at the address the kubelet advertises and
at https://kubernetes.default.svc; a KUBERNETES_BASE_URL pointing
somewhere else keeps the roots requests ships with, because the cluster
CA would reject every connection to that endpoint. tls_verify takes the
resolved address for this reason, so KUBERNETES_BASE_URL is settled
before the bundle is chosen. The sibling change in the FOCOM operator
applies the same rule.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review found four more things, three of them measured rather than read.
The address the bundle is chosen for is compared as an endpoint rather
than as a string. https://10.96.0.1 and https://10.96.0.1:443 are one
endpoint with one certificate, as are the two spellings of an IPv6
address and any casing of a DNS name; comparing the URLs as written sent
four such forms to the roots requests ships with. That fails closed on a
private-CA cluster rather than weakening anything, but it fails a
configuration that is correct. The sibling change in the FOCOM operator
takes the same rule.
Only one of the four API calls had its TLS setting pinned by a test.
Deleting verify=TLS_VERIFY from any of the other three left the suite
green, which is not a state a security invariant should be able to reach.
Every call site is now exercised and asserted on both the verify keyword
and the Authorization header; all eight deletions fail.
tests/conftest.py clears the ambient client configuration. This module
resolves the address and the bundle at import, so a fixture cannot undo
an ambient setting: by the time one runs, the test module has already
imported the package. It matters in a pod, where the kubelet injects
KUBERNETES_SERVICE_HOST even when automountServiceAccountToken is false,
so a CI pod with no service account volume looks like a cluster with no
CA. Running pytest directly under those variables failed collection
before this and passes after. tox happens to filter them out, so the job
in nephio-project#1175 was already green, but that is incidental and this is not.
Releases of nephio-project/catalog set KUBERNETES_BASE_URL to the
in-cluster DNS name, and that value cannot be removed in this commit
because it lives in another repository. Nothing publishes the two
atomically, so between this merge and catalog#146 a pod can run the new
image with the old package. In a pod that exact value now resolves to
the advertised address, with a warning: the same server, named the way
Kubernetes certifies it. Any other endpoint, any path, and anything
outside a pod is left alone. The FOCOM change needs no such bridge, as
its manifests are in this repository and change in the same commit.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review narrowed the bridge and found a hole in the README.
The bridge applies only where nothing else about TLS was configured.
Scoping it on the address alone was wrong: an operator that sets
KUBERNETES_CA_FILE has named the endpoint its bundle certifies, and one
that sets UNSAFE_SKIP_TLS_VERIFY has chosen its endpoint deliberately.
Substituting the advertised address under either turns a working proxy
into a hostname mismatch, since the address is resolved before the trust
policy is read. Only the deployment that sets the legacy value and
nothing else is rewritten, which is the one the bridge exists for.
The README's token refresh destroyed a valid token whenever it failed.
The redirection creates the temporary file before kubectl runs, so an
expired kubeconfig or an unreachable API server left it empty and the
rename put that over a credential that still worked:
$ printf 'a-valid-token' > /tmp/o2ims-token
$ tmp=$(mktemp ...); false > "$tmp"; mv -f "$tmp" /tmp/o2ims-token
$ stat -c%s /tmp/o2ims-token
0
That is the outage the rename is there to prevent, and it now surfaces
as the empty-token error this change adds. The recipe is a function that
discards the temporary file and reports the failure instead. Reading the
CA also uses --flatten, so a kubeconfig holding a path rather than
inline data works without a second command, and without that path being
resolved against the wrong directory.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Reviewing that change turned up two more. Every rejection from
validate_api_server_url is a RuntimeError naming what is wrong, except
that urlsplit raises on its own for an unclosed IPv6 bracket and that
ValueError reached the caller unchanged; at import it surfaced as a
traceback rather than as the message the other cases produce. And
tls_origin is now pinned by a test of the helper rather than only of its
callers, which cannot tell whether the scheme is part of an origin
because plaintext never reaches them. Dropping the scheme, the address
normalization or the default port each fails it. The FOCOM sibling has
the same pair of tests.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Running shellcheck over the README found the same failure shape two
lines above the one review reported: export always succeeds, so it hid
a kubectl that had failed, and the empty value it exported reads as
unset, which falls back to the in-cluster name. The address is assigned
first and only exported once it is non-empty.
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
478fef0 to
0527e17
Compare
Second of a small series on the operators' Kubernetes clients, after #1168. The first commit makes the operator verify the Kubernetes API server's certificate; the second stops building the bearer token through a shell and re-reads it per request.
controllers/utils.pyresolves the bearer token by interpolating an environment variable into a shell command:TOKENis documented as a path —tests/create-cluster.shsets it to/tmp/porch-token— but it reaches a shell verbatim, so whatever it contains runs:Two things are visible in that output. The injected
touchexecuted, and the token came back empty:catwrote its complaint to stderr and.read()returned"". CWE-78, and a failure mode that hides itself.What the empty token does
HEADERS_DICTis built from it at import, so the operator starts withAuthorization: Bearerand every call to the API server answers 401. The callers turn that into{"status": False, "reason": "unauthorized"}, which is what a reader of the logs sees — nothing points at the token file.The same structure has a second consequence that has nothing to do with the shell. The header is built once and never rebuilt, while Kubernetes rotates the projected token it mounts into the pod. Once the value read at startup expires, the operator gets 401 on everything until someone restarts it, and reports the same
unauthorizedon the way down.Why this is not a two-line patch
Swapping
os.popenforopen()is the obvious fix, and on its own it forces a choice about the unreadable-file case thatcatcurrently papers over. Both available answers are bad:pytest, which imports the module before its fixtures run.Reading the token where it is used answers the question instead of choosing: the caller sees the real error, at the point where it can be reported, and rotation stops being a problem because there is no longer a value cached from startup.
The
.strip()is not cosmetic. A token file written withechoends in a newline, andrequestsaccepts such a header value whilehttp.clientrejects it on the wire:Proof manifests
Run in the image the operator ships from, against
git archive HEADrather than a working directory, on a laptop and inside a simulated pod:That second run is not decoration. An earlier version of this branch passed on a laptop and failed eight tests in a pod, because the fixture that claimed to isolate the environment did not clear
KUBERNETES_SERVICE_HOST.Restoring the original semantics —
os.popenback inread_token, and the token captured once at startup instead of per request — fails thirteen of them:The sixth,
test_token_surroundings_are_stripped[tok], passes under both, which is correct: a token with nothing around it needs no stripping.The rotation test is the one the old shape made impossible to write. It changes the file between two requests and reads back what actually went out:
The injection test's payload only ever writes inside pytest's
tmp_path, and asserts both that the read fails and that the marker was not created.flake8 controllers/utils.pyreports 18 findings onmainand 17 here; all are pre-existingE501s and the patch adds none. As with #1169,.prow.yamlnever runstoxand the rootmake unitonly walks directories containing ago.mod, so none of this runs in CI today — the runs above are local against the pinnedrequirements.txt.What review changed
Two defects survived the first version, and the second one was hiding the first:
Authorization: Bearer— the exact failure this PR claims to remove, moved from "catfailed" to "the file is there and empty".read_tokennow refuses an empty result, and refuses a token containing internal whitespace, whichhttp.clientwould reject on the wire anyway.Bearer, and none of them asserted the header, so the passing count proved nothing about token handling. The fixture now writes a real token throughmonkeypatch— which also stops it leavingTOKENpointing at a deleted path after the module finishes — and one existing request test now pins the header value.Four more came out of testing the code against what it actually talks to rather than against its own assumptions:
The mounted CA was pinned to whatever endpoint this was pointed at.
tls_verifyused the bundle at/var/run/secrets/.../ca.crtwhenever it existed, so an operator in a pod withKUBERNETES_BASE_URLset to another API server had the cluster CA applied to a server that CA does not certify, and every request to it would fail. The bundle now follows the address: the cluster serves its API at the advertised host and athttps://kubernetes.default.svc, and anything else keeps the rootsrequestsships with.tls_verifytakes the resolved address for this reason. The FOCOM sibling had the same defect and takes the same rule.The validated host was not always the host connected to. Comparing
validate_api_server_urlagainst whatrequestsresolves, rather than reading either, turned up three disagreements:urlsplitdrops a tab inside the authority and keeps a NUL, whilerequestspercent-encodes both.Control characters and port zero are now refused. Endpoints are also compared as TLS origins rather than as strings, so
https://10.96.0.1andhttps://10.96.0.1:443— one endpoint with one certificate, as are the two spellings of an IPv6 address and any casing of a DNS name — no longer take different trust paths. Four such forms were being sent to the public roots; that fails closed on a private-CA cluster rather than weakening anything, but it fails a configuration that is correct.KUBERNETES_BASE_URLis operator configuration rather than attacker input, so this is not an escalation, but a client that carries a bearer token should refuse an address it cannot agree on. The Go validator was measured the same way and already agrees withnet/http, which rejects these at parse time.Only one of the four API calls had its TLS setting pinned. Deleting
verify=TLS_VERIFYfrom any of the other three left the suite green — not a state a security invariant should be able to reach. Every call site is now exercised and asserted on bothverifyandAuthorization; all eight deletions fail.Test collection depended on the pod it ran in. This module resolves the address and the bundle at import, so a fixture cannot undo an ambient setting: by the time one runs, the test module has already imported the package.
tests/conftest.pyclears it first, because pytest loads conftest before test modules. It matters because the kubelet injectsKUBERNETES_SERVICE_HOSTeven whenautomountServiceAccountTokenis false, so Run the o2ims Python tests in CI #1175's CI pod looks like a cluster with no CA. Running pytest directly under those variables failed collection before, and passes after. tox happens to filter them out, so Run the o2ims Python tests in CI #1175 was already green — incidental, and now not relied on.A rollout window across two repositories. My earlier claim that this has no effect on a healthy pod was too strong. Catalog releases set
KUBERNETES_BASE_URL=https://kubernetes.default.svc, which Kubernetes does not guarantee a serving certificate for, and that value cannot be removed in this commit because it lives in nephio-project/catalog. The postsubmit publishes this code as:latestthe moment it merges, so between here and catalog#146 a pod can run the new image with the old package. In a pod, that exact value now resolves to the advertised address with a warning — the same server, named the way Kubernetes certifies it. Any other endpoint, any path, and anything outside a pod is untouched. The FOCOM sibling needs no such bridge, because its manifests are in this repository and change in the same commit.Worth flagging separately for whoever merges: the catalog pins
:latestwithimagePullPolicy: IfNotPresent, so merging this does not by itself replace the image on a node that already cached one. catalog#146 pinning an immutable tag is what actually rolls the fix out.Rotation coverage was also too narrow: it only exercised
get_capi_cluster. The production path does a GET and then a POST insidecreate_package_variant, so there is now a test that rotates the file between those two and asserts the second request carries the new token.Effect on existing deployments
None in a healthy pod: the token file is there and is now read per request instead of once. A deployment that was running on an empty token was already failing every call with 401 and now gets a
FileNotFoundErrornaming the path it could not read, which is the same outage with a usable message.HEADERS_DICTis replaced byBASE_HEADERSplusrequest_headers(). Nothing outsidecontrollers/utils.pyreferenced it. The autouse fixture intests/test_utils.pythat setsTOKENto a temporary file starts having an effect for the first time; it previously ran after the module had already been imported and read.Notes for maintainers
HTTPS_VERIFYoros.popencould have come back. The merge is asserted, not assumed: the resolution checks that noos.popen,disable_warnings,bool(os.getenv("HTTPS_VERIFY"or127.0.0.1:8080survives, and that all four call sites carry bothrequest_headers()andTLS_VERIFY.status=Falsewhichcluster_creation_requestreports asprogressingbefore a 30-minute generic timeout — so the clear error this PR produces can still reach the user as a slow timeout. Raised as [o2ims] API calls have no timeout and configuration failures are reported as progressing #1174 rather than folded in here.SECURITY.mdasks that vulnerabilities not be reported through public issues. I have not opened a public issue for it. Say the word and I will route it through sig-security@lists.nephio.org before this goes further.