Skip to content

Commit ab4f152

Browse files
authored
fix(backend): honour kubeconfig CA under Bun's native fetch (#319)
Signed-off-by: Suraj Deshmukh <suraj.deshmukh@microsoft.com>
1 parent 5c490cf commit ab4f152

8 files changed

Lines changed: 415 additions & 32 deletions

File tree

backend/src/lib/kubeconfig.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2+
import * as k8s from '@kubernetes/client-node';
3+
import { kubeConfigToBunTls, BunTlsHttpLibrary, makeApiClient } from './kubeconfig';
4+
5+
/**
6+
* Regression guards for the Bun TLS shim (`kubeConfigToBunTls`,
7+
* `BunTlsHttpLibrary`, `makeApiClient`). This is security-sensitive auth/TLS
8+
* code: the most important assertion in this file is that the **default path
9+
* never disables certificate verification** — see `does NOT set
10+
* rejectUnauthorized on the default path`.
11+
*
12+
* Tests build real `KubeConfig` objects via `loadFromOptions` (no disk/network)
13+
* rather than mocking SDK internals, so they keep working across SDK patch bumps.
14+
*/
15+
16+
// PEM-shaped placeholders. `applyToHTTPSOptions` only base64-decodes the *Data
17+
// fields into Buffers and copies them; it does not parse/validate the contents.
18+
const CA_PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakeCApem\n-----END CERTIFICATE-----\n';
19+
const CERT_PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakeClientpem\n-----END CERTIFICATE-----\n';
20+
const KEY_PEM = '-----BEGIN PRIVATE KEY-----\nMIIBfakeKeypem\n-----END PRIVATE KEY-----\n';
21+
22+
const b64 = (s: string) => Buffer.from(s).toString('base64');
23+
24+
interface KcOpts {
25+
skipTLSVerify?: boolean;
26+
tlsServerName?: string;
27+
token?: string;
28+
withClientCert?: boolean;
29+
caData?: string | null;
30+
}
31+
32+
function makeKubeConfig(opts: KcOpts = {}): k8s.KubeConfig {
33+
const kc = new k8s.KubeConfig();
34+
const cluster: Record<string, unknown> = {
35+
name: 'test-cluster',
36+
server: 'https://api.example.test:443',
37+
skipTLSVerify: !!opts.skipTLSVerify,
38+
};
39+
if (opts.caData !== null) cluster.caData = opts.caData ?? b64(CA_PEM);
40+
if (opts.tlsServerName) cluster.tlsServerName = opts.tlsServerName;
41+
42+
const user: Record<string, unknown> = { name: 'test-user' };
43+
if (opts.token) user.token = opts.token;
44+
if (opts.withClientCert) {
45+
user.certData = b64(CERT_PEM);
46+
user.keyData = b64(KEY_PEM);
47+
}
48+
49+
kc.loadFromOptions({
50+
clusters: [cluster as any],
51+
users: [user as any],
52+
contexts: [{ name: 'test-ctx', cluster: 'test-cluster', user: 'test-user' }],
53+
currentContext: 'test-ctx',
54+
});
55+
return kc;
56+
}
57+
58+
describe('kubeConfigToBunTls', () => {
59+
it('maps ca/cert/key into the Bun tls option', async () => {
60+
const tls = await kubeConfigToBunTls(makeKubeConfig({ withClientCert: true }));
61+
expect(tls).toBeDefined();
62+
expect(Buffer.isBuffer(tls!.ca)).toBe(true);
63+
expect(Buffer.isBuffer(tls!.cert)).toBe(true);
64+
expect(Buffer.isBuffer(tls!.key)).toBe(true);
65+
expect(tls!.ca!.toString()).toBe(CA_PEM);
66+
});
67+
68+
it('maps the kubeconfig SNI (servername) to Bun camelCase serverName', async () => {
69+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', tlsServerName: 'sni.override.test' }));
70+
expect(tls).toBeDefined();
71+
expect(tls!.serverName).toBe('sni.override.test');
72+
});
73+
74+
it('does NOT set serverName when the kubeconfig has no tls-server-name', async () => {
75+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok' }));
76+
// ca is still present, so tls is defined; serverName must be absent.
77+
expect(tls).toBeDefined();
78+
expect(tls!.serverName).toBeUndefined();
79+
});
80+
81+
it('sets rejectUnauthorized=false when skipTLSVerify is true', async () => {
82+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', skipTLSVerify: true }));
83+
expect(tls).toBeDefined();
84+
expect(tls!.rejectUnauthorized).toBe(false);
85+
});
86+
87+
// *** KEY SECURITY REGRESSION GUARD ***
88+
it('does NOT set rejectUnauthorized on the default (verifying) path', async () => {
89+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok' }));
90+
expect(tls).toBeDefined();
91+
// Must be absent (not `true`, not `false`) so Bun keeps verification ON.
92+
expect(tls!.rejectUnauthorized).toBeUndefined();
93+
expect(Object.prototype.hasOwnProperty.call(tls!, 'rejectUnauthorized')).toBe(false);
94+
});
95+
96+
it('returns undefined when the kubeconfig configures no TLS material', async () => {
97+
// No CA, no client cert, token auth, verification left on → nothing to map.
98+
const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', caData: null }));
99+
expect(tls).toBeUndefined();
100+
});
101+
});
102+
103+
describe('BunTlsHttpLibrary.send', () => {
104+
const realFetch = globalThis.fetch;
105+
let calls: Array<{ url: string; options: any }>;
106+
107+
beforeEach(() => {
108+
calls = [];
109+
});
110+
afterEach(() => {
111+
globalThis.fetch = realFetch;
112+
});
113+
114+
function stubFetch(status = 200, body = '{"ok":true}') {
115+
globalThis.fetch = (async (url: any, options: any) => {
116+
calls.push({ url: String(url), options });
117+
return new Response(body, { status, headers: { 'content-type': 'application/json' } });
118+
}) as typeof fetch;
119+
}
120+
121+
function makeRequest(): k8s.RequestContext {
122+
const req = new k8s.RequestContext('https://api.example.test:443/healthz', k8s.HttpMethod.GET);
123+
// Mirror how the generated client applies auth before send() runs.
124+
req.setHeaderParam('Authorization', 'Bearer test-token-123');
125+
return req;
126+
}
127+
128+
it('passes the mapped tls material to fetch', async () => {
129+
stubFetch();
130+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ withClientCert: true, tlsServerName: 'sni.test' }));
131+
await lib.send(makeRequest()).toPromise();
132+
133+
expect(calls).toHaveLength(1);
134+
const tls = calls[0].options.tls;
135+
expect(tls).toBeDefined();
136+
expect(Buffer.isBuffer(tls.ca)).toBe(true);
137+
expect(tls.serverName).toBe('sni.test');
138+
});
139+
140+
it('preserves the Authorization header applied upstream (auth survives the override)', async () => {
141+
stubFetch();
142+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'ignored' }));
143+
await lib.send(makeRequest()).toPromise();
144+
145+
const headers = new Headers(calls[0].options.headers);
146+
expect(headers.get('Authorization')).toBe('Bearer test-token-123');
147+
});
148+
149+
it('omits tls entirely when the kubeconfig configures none', async () => {
150+
stubFetch();
151+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'tok', caData: null }));
152+
await lib.send(makeRequest()).toPromise();
153+
expect(calls[0].options.tls).toBeUndefined();
154+
});
155+
156+
it('returns a ResponseContext (not a thrown error) for a non-2xx response', async () => {
157+
stubFetch(404, '{"kind":"Status","code":404}');
158+
const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'tok' }));
159+
const res = await lib.send(makeRequest()).toPromise();
160+
161+
expect(res).toBeInstanceOf(k8s.ResponseContext);
162+
expect(res.httpStatusCode).toBe(404);
163+
expect(await res.body.text()).toContain('Status');
164+
});
165+
166+
it('resolves the kubeconfig TLS material only once across multiple requests', async () => {
167+
stubFetch();
168+
const kc = makeKubeConfig({ withClientCert: true });
169+
let applyCount = 0;
170+
const orig = kc.applyToHTTPSOptions.bind(kc);
171+
kc.applyToHTTPSOptions = (async (opts: any) => {
172+
applyCount += 1;
173+
return orig(opts);
174+
}) as typeof kc.applyToHTTPSOptions;
175+
176+
const lib = new BunTlsHttpLibrary(kc);
177+
await lib.send(makeRequest()).toPromise();
178+
await lib.send(makeRequest()).toPromise();
179+
await lib.send(makeRequest()).toPromise();
180+
181+
// Cached after the first call — guards against re-running the auth/cert
182+
// pipeline (e.g. exec credential plugins) on every request.
183+
expect(applyCount).toBe(1);
184+
});
185+
});
186+
187+
describe('makeApiClient', () => {
188+
it('builds a typed API client wired to the Bun TLS http library', () => {
189+
const api = makeApiClient(makeKubeConfig({ token: 'tok' }), k8s.CoreV1Api);
190+
expect(api).toBeInstanceOf(k8s.CoreV1Api);
191+
});
192+
193+
it('throws when the kubeconfig has no active cluster', () => {
194+
const kc = new k8s.KubeConfig();
195+
expect(() => makeApiClient(kc, k8s.CoreV1Api)).toThrow('No active cluster!');
196+
});
197+
});

backend/src/lib/kubeconfig.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,196 @@ export function loadKubeConfig(): k8s.KubeConfig {
3333

3434
return kc;
3535
}
36+
37+
/**
38+
* TLS material in the shape Bun's native `fetch` understands via its
39+
* non-standard per-request `tls` option (see Bun's `TLSOptions`).
40+
*
41+
* Field names follow Bun, not Node: in particular SNI is `serverName`
42+
* (camelCase), whereas the kubeconfig/Node side spells it `servername`.
43+
*/
44+
export interface BunTlsOptions {
45+
ca?: Buffer;
46+
cert?: Buffer;
47+
key?: Buffer;
48+
passphrase?: string;
49+
serverName?: string;
50+
rejectUnauthorized?: boolean;
51+
}
52+
53+
/**
54+
* Translate a `KubeConfig`'s TLS material into Bun's `fetch` `tls` option.
55+
*
56+
* This is the **single source of truth** for kubeconfig → Bun TLS mapping. Both
57+
* {@link BunTlsHttpLibrary} (typed-API clients) and `proxyServiceRequest`
58+
* (raw service-proxy `fetch`) must call it, so the two paths cannot drift —
59+
* e.g. one gaining SNI support while the other silently misses it.
60+
*
61+
* It asks the SDK for the same `https.Agent` options its Node path would use
62+
* (`applyToHTTPSOptions`, verified against `@kubernetes/client-node@1.4.0`
63+
* `config.js`), then re-maps the subset Bun honours:
64+
* - `ca`/`cert`/`key` — CA bundle + client cert/key
65+
* - `passphrase` — passphrase for an encrypted client key
66+
* - `servername` → `serverName` — SNI / hostname-verification override
67+
* (`cluster.tls-server-name`)
68+
* - `rejectUnauthorized:false` — only when `skipTLSVerify` (or the SDK)
69+
* explicitly disabled verification; the
70+
* default path leaves it unset so Bun keeps
71+
* verification ON.
72+
*
73+
* KNOWN LIMITATIONS (kubeconfig features the SDK's Node agent supports but
74+
* Bun's `fetch` cannot express, so they are intentionally dropped here):
75+
* - `pfx` (PKCS#12 client certs) — Bun's `TLSOptions` has no `pfx` field.
76+
* PEM `cert`/`key` work; `.pfx`-based auth does not under Bun.
77+
* - `cluster.proxy-url` — Bun has no per-request proxy-agent option;
78+
* kubeconfig-configured HTTP/SOCKS proxies are not honoured.
79+
*
80+
* @returns the mapped options, or `undefined` if the kubeconfig configured no
81+
* TLS material at all (so callers can omit `tls` entirely).
82+
*/
83+
export async function kubeConfigToBunTls(kc: k8s.KubeConfig): Promise<BunTlsOptions | undefined> {
84+
// Ask the SDK for the Node `https.Agent` options it would build, then re-map
85+
// the subset Bun honours. `servername`/`pfx`/`passphrase` are populated by
86+
// the SDK even though our narrowed type only names what we forward.
87+
const httpsOptions: {
88+
ca?: Buffer;
89+
cert?: Buffer;
90+
key?: Buffer;
91+
passphrase?: string;
92+
servername?: string;
93+
rejectUnauthorized?: boolean;
94+
} = {};
95+
await kc.applyToHTTPSOptions(httpsOptions as any);
96+
97+
const tls: BunTlsOptions = {};
98+
if (httpsOptions.ca) tls.ca = httpsOptions.ca;
99+
if (httpsOptions.cert) tls.cert = httpsOptions.cert;
100+
if (httpsOptions.key) tls.key = httpsOptions.key;
101+
if (httpsOptions.passphrase) tls.passphrase = httpsOptions.passphrase;
102+
// Node spells SNI `servername`; Bun's `tls` option spells it `serverName`.
103+
if (httpsOptions.servername) tls.serverName = httpsOptions.servername;
104+
if (kc.getCurrentCluster()?.skipTLSVerify || httpsOptions.rejectUnauthorized === false) {
105+
tls.rejectUnauthorized = false;
106+
}
107+
108+
return Object.keys(tls).length > 0 ? tls : undefined;
109+
}
110+
111+
/**
112+
* Bun-compatible HTTP library for `@kubernetes/client-node`.
113+
*
114+
* WHY THIS EXISTS:
115+
* The client's default `IsomorphicFetchHttpLibrary` imports `node-fetch` and
116+
* passes the kubeconfig CA (and client cert/key) as a Node.js `https.Agent`
117+
* (`request.getAgent()`). Bun's runtime resolves `node-fetch` to its native
118+
* `fetch`, which **ignores** the Node `https.Agent` entirely — it only honours
119+
* TLS material supplied via the per-request `tls` option. The CA therefore never
120+
* reaches the TLS stack, so every request to a cluster whose API server uses a
121+
* private CA (e.g. AKS) fails with `UNABLE_TO_VERIFY_LEAF_SIGNATURE`.
122+
*
123+
* This subclass overrides `send()` to call Bun's native `fetch` directly,
124+
* translating the kubeconfig's TLS material (via {@link kubeConfigToBunTls})
125+
* into the `tls` option Bun understands. Auth headers (Bearer tokens, etc.) are
126+
* still applied by the generated client via `authMethods` before `send()` runs,
127+
* so we only need to re-inject the TLS material here. It shares the exact same
128+
* mapping helper as `proxyServiceRequest` in `kubernetes.ts`.
129+
*
130+
* The TLS material is resolved **once** and cached: `applyToHTTPSOptions` re-runs
131+
* the kubeconfig's full option pipeline (re-reading cert files from disk and, for
132+
* exec/OIDC users, re-invoking the auth plugin). Doing that per request would run
133+
* exec credentials twice on every call — once here and once in the auth pipeline
134+
* the generated client already applied. A KubeConfig's TLS material is fixed for
135+
* the lifetime of a client, so caching is safe; auth headers are unaffected
136+
* because they are applied upstream per request, not here.
137+
*
138+
* The response is wrapped exactly as the upstream library does: an `Observable`
139+
* constructed from a `Promise<ResponseContext>` (see `rxjsStub`), with a
140+
* `ResponseBody` exposing `text()` and `binary()`.
141+
*/
142+
export class BunTlsHttpLibrary extends k8s.IsomorphicFetchHttpLibrary {
143+
private tlsPromise?: Promise<BunTlsOptions | undefined>;
144+
145+
constructor(private readonly kc: k8s.KubeConfig) {
146+
super();
147+
}
148+
149+
/** Resolve the kubeconfig's Bun `tls` material once and memoise it. */
150+
private getTls(): Promise<BunTlsOptions | undefined> {
151+
if (!this.tlsPromise) {
152+
this.tlsPromise = kubeConfigToBunTls(this.kc);
153+
}
154+
return this.tlsPromise;
155+
}
156+
157+
send(request: k8s.RequestContext): k8s.Observable<k8s.ResponseContext> {
158+
const responsePromise = (async (): Promise<k8s.ResponseContext> => {
159+
const tls = await this.getTls();
160+
161+
// NOTE: the generated API classes used by this backend send only JSON or
162+
// empty bodies. The SDK's Node path can produce `form-data` multipart
163+
// bodies (e.g. some file-upload endpoints) which would NOT serialise under
164+
// Bun's `fetch`; if a future caller hits such an endpoint, body handling
165+
// here must be revisited.
166+
const fetchOptions: RequestInit & { tls?: BunTlsOptions } = {
167+
method: request.getHttpMethod().toString(),
168+
body: request.getBody() as BodyInit | undefined,
169+
headers: request.getHeaders(),
170+
signal: request.getSignal(),
171+
};
172+
if (tls) {
173+
fetchOptions.tls = tls;
174+
}
175+
176+
const response = await fetch(request.getUrl(), fetchOptions);
177+
178+
const headers: Record<string, string> = {};
179+
response.headers.forEach((value, name) => {
180+
headers[name] = value;
181+
});
182+
183+
return new k8s.ResponseContext(response.status, headers, {
184+
text: () => response.text(),
185+
binary: async () => Buffer.from(await response.arrayBuffer()),
186+
});
187+
})();
188+
189+
return new k8s.Observable<k8s.ResponseContext>(responsePromise);
190+
}
191+
}
192+
193+
/**
194+
* Build a Kubernetes API client that works under Bun.
195+
*
196+
* Drop-in replacement for `kc.makeApiClient(ApiClass)`. It reproduces the SDK's
197+
* own `makeApiClient` wiring (`createConfiguration` with the kubeconfig as the
198+
* `default` auth method and a `ServerConfiguration` for the current cluster) but
199+
* swaps in {@link BunTlsHttpLibrary} so the kubeconfig CA is honoured on Bun's
200+
* native `fetch`.
201+
*
202+
* All backend services must construct their clients through this helper rather
203+
* than calling `kc.makeApiClient(...)` directly; otherwise requests to clusters
204+
* with a private CA fail with `UNABLE_TO_VERIFY_LEAF_SIGNATURE` under Bun.
205+
*
206+
* NOTE (SDK coupling): this hand-reproduces the SDK's own `makeApiClient` wiring
207+
* (`createConfiguration` + `ServerConfiguration`) and subclasses
208+
* `IsomorphicFetchHttpLibrary`. Verified against `@kubernetes/client-node@1.4.0`.
209+
* These are generated/internal surfaces — re-check this wiring (and the
210+
* `kubeConfigToBunTls` field mapping) when bumping the SDK across a major version.
211+
*/
212+
export function makeApiClient<T extends k8s.ApiType>(
213+
kc: k8s.KubeConfig,
214+
apiClientType: k8s.ApiConstructor<T>
215+
): T {
216+
const cluster = kc.getCurrentCluster();
217+
if (!cluster) {
218+
throw new Error('No active cluster!');
219+
}
220+
221+
const configuration = k8s.createConfiguration({
222+
baseServer: new k8s.ServerConfiguration(cluster.server, {}),
223+
authMethods: { default: kc },
224+
httpApi: new BunTlsHttpLibrary(kc),
225+
});
226+
227+
return new apiClientType(configuration);
228+
}

0 commit comments

Comments
 (0)