Skip to content

[security][o2ims] Verify Kubernetes API TLS certificates and read the token without a shell - #1170

Open
thc1006 wants to merge 2 commits into
nephio-project:mainfrom
thc1006:o2ims-read-token-without-a-shell
Open

[security][o2ims] Verify Kubernetes API TLS certificates and read the token without a shell#1170
thc1006 wants to merge 2 commits into
nephio-project:mainfrom
thc1006:o2ims-read-token-without-a-shell

Conversation

@thc1006

@thc1006 thc1006 commented Aug 16, 2026

Copy link
Copy Markdown

This supersedes #1169, which is closed. Both fixes land here as two reviewable commits against one tree.

They were separate PRs, and that was wrong. The main-branch postsubmit publishes nephio/o2ims-operator:latest on every merge:

--destination=nephio/o2ims-operator:${BUILD_ID} --destination=nephio/o2ims-operator:latest

Merging the TLS fix alone would therefore publish an image that verifies certificates but still builds its bearer token through os.popen and still never re-reads it — while the README it ships with says the kubelet rotates that token. One merge, one image, no window in which a published latest carries a defect the project already has a fix for.

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.py resolves the bearer token by interpolating an environment variable into a shell command:

TOKEN = os.getenv("TOKEN", "/var/run/secrets/kubernetes.io/serviceaccount/token")
TOKEN = os.popen(f"cat {TOKEN}").read()

TOKEN is documented as a path — tests/create-cluster.sh sets it to /tmp/porch-token — but it reaches a shell verbatim, so whatever it contains runs:

$ TOKEN='/absent; touch /tmp/tmpXXXX/pwned'
os.popen : token=''  marker created: True
open()   : FileNotFoundError          marker created: False

Two things are visible in that output. The injected touch executed, and the token came back empty: cat wrote its complaint to stderr and .read() returned "". CWE-78, and a failure mode that hides itself.

What the empty token does

HEADERS_DICT is built from it at import, so the operator starts with Authorization: Bearer and 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 unauthorized on the way down.

Why this is not a two-line patch

Swapping os.popen for open() is the obvious fix, and on its own it forces a choice about the unreadable-file case that cat currently papers over. Both available answers are bad:

  • Raise at import, and the operator refuses to start anywhere the file is absent — including pytest, which imports the module before its fixtures run.
  • Swallow the error, and today's silent 401s survive the fix.

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.

def read_token() -> str:
    """Return the token, read fresh so that rotation is picked up."""
    path = os.getenv("TOKEN", IN_CLUSTER_TOKEN_FILE)
    with open(path, encoding="utf-8") as token_file:
        return token_file.read().strip()


def request_headers() -> dict:
    """Return the API server request headers, carrying the current token."""
    return {**BASE_HEADERS, "Authorization": f"Bearer {read_token()}"}

The .strip() is not cosmetic. A token file written with echo ends in a newline, and requests accepts such a header value while http.client rejects it on the wire:

'Bearer tok'    -> sent, server saw 'Bearer tok'
'Bearer tok\n'  -> ValueError: Invalid header value b'Bearer tok\n'

Proof manifests

$ pytest --disable-warnings -q      # combined tree, both commits
135 passed in 0.94s

Run in the image the operator ships from, against git archive HEAD rather than a working directory, on a laptop and inside a simulated pod:

$ podman run --rm -v $PWD:/w -w /w \
    -e KUBERNETES_SERVICE_HOST=10.96.0.1 -e KUBERNETES_SERVICE_PORT=443 \
    python:3.12.10-alpine3.21 sh -c '...mount token and ca.crt...; tox -e py312'
135 passed in 1.36s

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.popen back in read_token, and the token captured once at startup instead of per request — fails thirteen of them:

$ pytest --disable-warnings -q      # with main's semantics restored
FAILED tests/test_utils.py::test_token_surroundings_are_stripped[tok\n]
FAILED tests/test_utils.py::test_token_surroundings_are_stripped[  tok\r\n]
FAILED tests/test_utils.py::test_an_empty_or_blank_token_is_refused[]
FAILED tests/test_utils.py::test_an_empty_or_blank_token_is_refused[ ]
FAILED tests/test_utils.py::test_an_empty_or_blank_token_is_refused[\n]
FAILED tests/test_utils.py::test_an_empty_or_blank_token_is_refused[\r\n]
FAILED tests/test_utils.py::test_an_empty_or_blank_token_is_refused[ \t \r\n]
FAILED tests/test_utils.py::test_a_token_with_internal_whitespace_is_refused[tok en]
FAILED tests/test_utils.py::test_a_token_with_internal_whitespace_is_refused[tok\nen]
FAILED tests/test_utils.py::test_a_token_with_internal_whitespace_is_refused[tok\ten]
FAILED tests/test_utils.py::test_an_invalid_token_stops_before_any_request_is_made
13 failed, 122 passed in 1.52s

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:

    get_capi_cluster(NAME, NAMESPACE)
    token_file.write_text("second")
    get_capi_cluster(NAME, NAMESPACE)

    sent = [call.request.headers["Authorization"] for call in responses.calls]
    assert sent == ["Bearer first", "Bearer second"]

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.py reports 18 findings on main and 17 here; all are pre-existing E501s and the patch adds none. As with #1169, .prow.yaml never runs tox and the root make unit only walks directories containing a go.mod, so none of this runs in CI today — the runs above are local against the pinned requirements.txt.

What review changed

Two defects survived the first version, and the second one was hiding the first:

  • An empty or blank token file still produced Authorization: Bearer — the exact failure this PR claims to remove, moved from "cat failed" to "the file is there and empty". read_token now refuses an empty result, and refuses a token containing internal whitespace, which http.client would reject on the wire anyway.
  • The autouse fixture created an empty token file. Every pre-existing request test therefore ran with Bearer , and none of them asserted the header, so the passing count proved nothing about token handling. The fixture now writes a real token through monkeypatch — which also stops it leaving TOKEN pointing 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_verify used the bundle at /var/run/secrets/.../ca.crt whenever it existed, so an operator in a pod with KUBERNETES_BASE_URL set 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 at https://kubernetes.default.svc, and anything else keeps the roots requests ships with. tls_verify takes 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_url against what requests resolves, rather than reading either, turned up three disagreements: urlsplit drops a tab inside the authority and keeps a NUL, while requests percent-encodes both.

    'https://good.example\t.evil'   validator saw 'good.example.evil'
                                    requests    saw 'good.example%09.evil'
    

    Control characters and port zero are now refused. Endpoints are also compared as TLS origins rather than as strings, so https://10.96.0.1 and https://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_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. The Go validator was measured the same way and already agrees with net/http, which rejects these at parse time.

  • Only one of the four API calls had its TLS setting pinned. Deleting verify=TLS_VERIFY from 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 both verify and Authorization; 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.py clears it first, because pytest loads conftest before test modules. It matters because the kubelet injects KUBERNETES_SERVICE_HOST even when automountServiceAccountToken is 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 :latest the 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 :latest with imagePullPolicy: 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 inside create_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 FileNotFoundError naming the path it could not read, which is the same outage with a usable message.

HEADERS_DICT is replaced by BASE_HEADERS plus request_headers(). Nothing outside controllers/utils.py referenced it. The autouse fixture in tests/test_utils.py that sets TOKEN to 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

  • This is now stacked on [security][o2ims] Verify Kubernetes API TLS certificates by default #1169 rather than forked beside it. The two branches previously shared a base and replaced the same block, so each head still contained the defect the other fixed; resolving that by hand later is exactly where HTTPS_VERIFY or os.popen could have come back. The merge is asserted, not assumed: the resolution checks that no os.popen, disable_warnings, bool(os.getenv("HTTPS_VERIFY" or 127.0.0.1:8080 survives, and that all four call sites carry both request_headers() and TLS_VERIFY.
  • Requests still have no timeout, and a token failure is flattened into status=False which cluster_creation_request reports as progressing before 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.
  • Release triage is yours. The change is confined to the o2ims operator's API client and its tests.
  • SECURITY.md asks 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.

@nephio-prow

nephio-prow Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign johnbelamaric for approval by writing /assign @johnbelamaric in a comment. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@nephio-prow

nephio-prow Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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 /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions 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>
@thc1006
thc1006 force-pushed the o2ims-read-token-without-a-shell branch from 13e1aa4 to 56c1545 Compare August 16, 2026 07:14
@thc1006 thc1006 changed the title [security][o2ims] Read the service account token without a shell [security][o2ims] Verify Kubernetes API TLS certificates and read the token without a shell Aug 17, 2026
@thc1006
thc1006 force-pushed the o2ims-read-token-without-a-shell branch from 56c1545 to a6f4b45 Compare August 18, 2026 13:20
@thc1006
thc1006 force-pushed the o2ims-read-token-without-a-shell branch 4 times, most recently from 2a94a93 to 7e5d8cc Compare August 20, 2026 09:31
thc1006 added a commit to thc1006/nephio that referenced this pull request Aug 20, 2026
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>
@thc1006
thc1006 force-pushed the o2ims-read-token-without-a-shell branch 2 times, most recently from 0b2c92b to 478fef0 Compare August 20, 2026 15:03
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>
@thc1006
thc1006 force-pushed the o2ims-read-token-without-a-shell branch from 478fef0 to 0527e17 Compare August 20, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant