Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion internal/api/src/client.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import Users from "./routes/users.client";
export interface ClientOptions {
baseURL?: string;
authToken?: string;
headers?: Record<string, string>;
fetch?: typeof globalThis.fetch;
}

export default class Client {
public authToken?: string;

protected readonly baseURL: URL;
protected readonly headers: Headers;
private readonly fetch?: typeof globalThis.fetch;

public readonly admin = {
Expand Down Expand Up @@ -49,6 +51,7 @@ export default class Client {
: "https://blink.coder.com")
);
this.fetch = options?.fetch;
this.headers = new Headers(options?.headers);
this.authToken = options?.authToken;
this.auth = new Auth(this, this.baseURL);
}
Expand All @@ -64,7 +67,7 @@ export default class Client {
}
) {
const url = new URL(path, this.baseURL);
const headers = new Headers();
const headers = new Headers(this.headers);
if (this.authToken) {
headers.set("Authorization", `Bearer ${this.authToken}`);
}
Expand Down
8 changes: 5 additions & 3 deletions internal/api/src/client.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ export default class Client extends BrowserClient {
public override websocket(path: string): WebSocket {
const url = new URL(path, this.baseURL);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const headers: Record<string, string> = {};
const headers = new Headers(this.headers);
if (this.authToken) {
headers.Authorization = `Bearer ${this.authToken}`;
headers.set("Authorization", `Bearer ${this.authToken}`);
}
// biome-ignore lint/suspicious/noExplicitAny: types are wrong
return new WebSocket(url.toString(), { headers } as any);
return new WebSocket(url.toString(), {
headers: Object.fromEntries(headers),
} as any);
}
}

Expand Down
40 changes: 40 additions & 0 deletions internal/api/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import BrowserClient from "./client.browser";

describe("client default headers", () => {
test("merges defaults, generated headers, and request overrides without leaking overrides", async () => {
const requests: Headers[] = [];
const defaults = {
"X-Gateway": "secret",
authorization: "default",
"content-type": "text/plain",
};
const client = new BrowserClient({
headers: defaults,
authToken: "session",
fetch: (async (_url, init) => {
requests.push(new Headers(init?.headers));
return new Response();
}) as typeof fetch,
});
defaults["X-Gateway"] = "changed";
await client.request("POST", "/api/test", "{}", {
headers: {
"x-gateway": "override",
AUTHORIZATION: "request",
"CONTENT-TYPE": "custom/type",
},
});
await client.request("POST", "/api/test", "{}");
expect(Object.fromEntries(requests[0]!)).toEqual({
"x-gateway": "override",
authorization: "request",
"content-type": "custom/type",
});
expect(Object.fromEntries(requests[1]!)).toEqual({
"x-gateway": "secret",
authorization: "Bearer session",
"content-type": "application/json",
});
});
});
3 changes: 3 additions & 0 deletions internal/site/lib/api-client.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Client from "@blink.so/api";
import { cache } from "react";
import { getSessionToken } from "@/app/(auth)/auth";
import { getAPIHeaders } from "./api-headers.server";

const getBaseUrl = (): string => {
return process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3005";
Expand All @@ -15,6 +16,7 @@ export const getAPIClient = cache(async (): Promise<Client> => {
return new Client({
baseURL: getBaseUrl(),
authToken: token,
headers: getAPIHeaders(),
});
});

Expand All @@ -25,5 +27,6 @@ export const getAPIClient = cache(async (): Promise<Client> => {
export const getPublicAPIClient = cache((): Client => {
return new Client({
baseURL: getBaseUrl(),
headers: getAPIHeaders(),
});
});
42 changes: 42 additions & 0 deletions internal/site/lib/api-headers.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { afterEach, expect, test } from "bun:test";
import { getAPIHeaders } from "./api-headers.server";

const original = process.env.BLINK_API_HEADERS;
afterEach(() => {
if (original === undefined) delete process.env.BLINK_API_HEADERS;
else process.env.BLINK_API_HEADERS = original;
});

test("absent or empty configuration adds no headers", () => {
delete process.env.BLINK_API_HEADERS;
expect(getAPIHeaders()).toBeUndefined();
for (const value of ["", " \n "]) {
process.env.BLINK_API_HEADERS = value;
expect(getAPIHeaders()).toBeUndefined();
}
});

test("reads and validates the current environment", () => {
process.env.BLINK_API_HEADERS = '{"X-Gateway":"secret"}';
expect(getAPIHeaders()).toEqual({ "x-gateway": "secret" });
process.env.BLINK_API_HEADERS = "{}";
expect(getAPIHeaders()).toEqual({});
});

test("invalid configuration fails without disclosing credentials", () => {
for (const value of [
"secret",
"null",
"[]",
'{"X-Gateway":42}',
'{"bad name":"secret"}',
JSON.stringify({ "X-Gateway": "secret\r\ninjected" }),
]) {
process.env.BLINK_API_HEADERS = value;
expect(getAPIHeaders).toThrow(
new Error(
"BLINK_API_HEADERS must be a JSON object of valid HTTP headers with string values"
)
);
}
});
24 changes: 24 additions & 0 deletions internal/site/lib/api-headers.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Read private gateway credentials only when constructing a server API client. */
export function getAPIHeaders(): Record<string, string> | undefined {
const value = process.env.BLINK_API_HEADERS;
if (!value?.trim()) return undefined;

try {
const parsed: unknown = JSON.parse(value);
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
Object.values(parsed).some((value) => typeof value !== "string")
) {
throw new Error();
}
// Validate HTTP names and values and normalize case before passing them on.
return Object.fromEntries(new Headers(parsed as Record<string, string>));
} catch {
// Parser and Headers errors can contain credentials; never propagate them.
throw new Error(
"BLINK_API_HEADERS must be a JSON object of valid HTTP headers with string values"
);
}
}
Loading