-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): let the deployment S2 client endpoints be overridden #4867
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add the required crumbs instrumentation to the new code.
As per coding guidelines, “Add crumbs as you write code” and mark lines with 📍 Affects 3 files
Source: Coding guidelines |
||
|
|
||
| function isPrivateIpv4(hostname: string): boolean { | ||
| const octets = hostname.split("."); | ||
| if (octets.length !== 4 || octets.some((o) => !/^\d{1,3}$/.test(o))) { | ||
| return false; | ||
| } | ||
|
|
||
| const [a, b] = octets.map(Number) as [number, number, number, number]; | ||
| return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); | ||
| } | ||
|
|
||
| // A single-label hostname (no dot) is a container or service name on a private network, which is | ||
| // how the self-hosted stack reaches S2, e.g. `http://s2/v1`. | ||
| function isPrivateHost(hostname: string): boolean { | ||
| return LOOPBACK_HOSTS.has(hostname) || !hostname.includes(".") || isPrivateIpv4(hostname); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Do not classify every single-label hostname as private. Line 16 accepts Require HTTPS for hostname-based endpoints, or enforce private resolved addresses in the transport for every connection. Do not rely on one-time DNS validation because DNS rebinding can change the destination. |
||
| } | ||
|
|
||
| // The S2 access token is sent as a bearer header to whatever endpoint is configured, so cleartext | ||
| // is only acceptable to a host that is not reachable from the public internet. | ||
| export function isValidS2Endpoint(value: string): boolean { | ||
| let url: URL; | ||
| try { | ||
| url = new URL(value); | ||
| } catch { | ||
| return false; | ||
| } | ||
|
|
||
| if (url.protocol === "https:") { | ||
| return true; | ||
| } | ||
|
|
||
| return url.protocol === "http:" && url.hostname !== "" && isPrivateHost(url.hostname); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| // Cached S2 read tokens are issued by whichever S2 service the endpoint names, and Redis outlives | ||
| // a restart, so a key scoped only by project would serve a token from the previous service after | ||
| // the endpoint changes. Hosted keeps its existing unscoped keys so nothing is invalidated. | ||
| export function s2CacheScope(endpoint: string | undefined): string { | ||
| return endpoint === undefined ? "" : `endpoint:${endpoint}:`; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import type { S2 } from "@s2-dev/streamstore"; | ||
| import { env } from "~/env.server"; | ||
| import { buildDeploymentS2Client } from "~/v3/s2ClientConfig"; | ||
|
|
||
| export function createDeploymentS2Client(): S2 | undefined { | ||
| if (env.S2_ENABLED !== "1") { | ||
| return undefined; | ||
| } | ||
|
|
||
| return buildDeploymentS2Client({ | ||
| accessToken: env.S2_ACCESS_TOKEN, | ||
| endpoint: env.S2_DEPLOYMENT_ENDPOINT, | ||
|
d-cs marked this conversation as resolved.
|
||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { buildDeploymentS2Client, deploymentS2ClientOptions } from "./s2ClientConfig"; | ||
|
|
||
| const BASIN = "trigger-local"; | ||
|
|
||
| describe("buildDeploymentS2Client", () => { | ||
| // The SDK resolves an endpoints key with undefined members to the same hosted URLs, so nothing | ||
| // on the built client distinguishes the two calls. Assert on the options instead. | ||
| it("hands the SDK no endpoints key at all when no endpoint is configured", () => { | ||
| expect(deploymentS2ClientOptions({ accessToken: "token" })).toEqual({ accessToken: "token" }); | ||
| expect(deploymentS2ClientOptions({ accessToken: "token" })).not.toHaveProperty("endpoints"); | ||
| }); | ||
|
|
||
| it("hands the SDK one endpoint for both hosts when configured", () => { | ||
| expect( | ||
| deploymentS2ClientOptions({ accessToken: "token", endpoint: "http://localhost:4566" }) | ||
| ).toEqual({ | ||
| accessToken: "token", | ||
| endpoints: { account: "http://localhost:4566", basin: "http://localhost:4566" }, | ||
| }); | ||
| }); | ||
|
|
||
| it("still resolves the SDK's hosted defaults when no endpoint is configured", () => { | ||
| const client = buildDeploymentS2Client({ accessToken: "token" }); | ||
|
|
||
| expect(client.endpoints.accountBaseUrl()).toBe("https://a.s2.dev/v1"); | ||
| expect(client.endpoints.basinBaseUrl(BASIN)).toBe(`https://${BASIN}.b.s2.dev/v1`); | ||
| expect(client.endpoints.includeBasinHeader).toBe(false); | ||
| }); | ||
|
|
||
| it("points both the account and basin hosts at a configured endpoint", () => { | ||
| const client = buildDeploymentS2Client({ | ||
| accessToken: "token", | ||
| endpoint: "http://localhost:4566", | ||
| }); | ||
|
|
||
| expect(client.endpoints.accountBaseUrl()).toBe("http://localhost:4566/v1"); | ||
| expect(client.endpoints.basinBaseUrl(BASIN)).toBe("http://localhost:4566/v1"); | ||
| expect(client.endpoints.includeBasinHeader).toBe(true); | ||
| }); | ||
|
|
||
| // A split configuration would send the access token to the hosted service while the operator | ||
| // believed the client was entirely local, so one value has to drive both hosts. | ||
| it("never leaves one host hosted while the other is overridden", () => { | ||
| const client = buildDeploymentS2Client({ | ||
| accessToken: "token", | ||
| endpoint: "http://localhost:4566", | ||
| }); | ||
|
|
||
| expect(client.endpoints.accountBaseUrl()).not.toContain("s2.dev"); | ||
| expect(client.endpoints.basinBaseUrl(BASIN)).not.toContain("s2.dev"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { S2 } from "@s2-dev/streamstore"; | ||
|
|
||
| export type DeploymentS2Config = { | ||
| accessToken: string; | ||
| endpoint?: string; | ||
| }; | ||
|
|
||
| type DeploymentS2ClientOptions = { | ||
| accessToken: string; | ||
| endpoints?: { account: string; basin: string }; | ||
| }; | ||
|
|
||
| // Exported so a test can pin the shape handed to the SDK: with no endpoint the options must carry | ||
| // no `endpoints` key, matching the call production already makes. | ||
| export function deploymentS2ClientOptions({ | ||
| accessToken, | ||
| endpoint, | ||
| }: DeploymentS2Config): DeploymentS2ClientOptions { | ||
| if (endpoint === undefined) { | ||
| return { accessToken }; | ||
| } | ||
|
|
||
| // One value drives both hosts. Overriding just one would send the access token to the hosted | ||
| // service while the other half went elsewhere. | ||
| return { accessToken, endpoints: { account: endpoint, basin: endpoint } }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export function buildDeploymentS2Client(config: DeploymentS2Config): S2 { | ||
| return new S2(deploymentS2ClientOptions(config)); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { s2CacheScope } from "~/v3/s2CacheScope"; | ||
|
|
||
| describe("s2CacheScope", () => { | ||
| // Hosted must keep the keys it already has in Redis, or every project takes a needless miss. | ||
| it("adds nothing when no endpoint is configured", () => { | ||
| expect(s2CacheScope(undefined)).toBe(""); | ||
| }); | ||
|
|
||
| // Redis outlives a restart, so a token issued by the previous S2 service must not be served | ||
| // once the endpoint changes. | ||
| it("gives each endpoint its own namespace", () => { | ||
| const local = s2CacheScope("http://localhost:4566"); | ||
| const other = s2CacheScope("http://s2/v1"); | ||
|
|
||
| expect(local).not.toBe(""); | ||
| expect(local).not.toBe(other); | ||
| expect(local).toBe(s2CacheScope("http://localhost:4566")); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { isValidS2Endpoint } from "~/utils/s2Endpoint"; | ||
|
|
||
| describe("isValidS2Endpoint", () => { | ||
| it("accepts https anywhere", () => { | ||
| expect(isValidS2Endpoint("https://a.s2.dev")).toBe(true); | ||
| expect(isValidS2Endpoint("https://s2.internal:4566/v1")).toBe(true); | ||
| }); | ||
|
|
||
| it("accepts http to a loopback host", () => { | ||
| expect(isValidS2Endpoint("http://localhost:4566")).toBe(true); | ||
| expect(isValidS2Endpoint("http://127.0.0.1:4566")).toBe(true); | ||
| expect(isValidS2Endpoint("http://[::1]:4566")).toBe(true); | ||
| }); | ||
|
|
||
| // This is how the self-hosted stack reaches S2, so rejecting it would force TLS on a private | ||
| // container network. | ||
| it("accepts http to a container or service name and a private address", () => { | ||
| expect(isValidS2Endpoint("http://s2/v1")).toBe(true); | ||
| expect(isValidS2Endpoint("http://s2:80/v1")).toBe(true); | ||
| expect(isValidS2Endpoint("http://10.0.0.5:4566")).toBe(true); | ||
| expect(isValidS2Endpoint("http://172.20.0.3")).toBe(true); | ||
| expect(isValidS2Endpoint("http://192.168.1.9")).toBe(true); | ||
| }); | ||
|
|
||
| // The access token is sent to whatever is configured, so cleartext to a routable host leaks it. | ||
| it("rejects http to a public host", () => { | ||
| expect(isValidS2Endpoint("http://s2.example.com")).toBe(false); | ||
| expect(isValidS2Endpoint("http://a.s2.dev")).toBe(false); | ||
| expect(isValidS2Endpoint("http://8.8.8.8")).toBe(false); | ||
| expect(isValidS2Endpoint("http://172.32.0.1")).toBe(false); | ||
| }); | ||
|
|
||
| // All four pass zod's `.url()`, which is why the schema refines on this instead. | ||
| it("rejects malformed and non-http schemes", () => { | ||
| expect(isValidS2Endpoint("htp:/localhost:4566")).toBe(false); | ||
| expect(isValidS2Endpoint("ftp://localhost")).toBe(false); | ||
| expect(isValidS2Endpoint("not a url")).toBe(false); | ||
| expect(isValidS2Endpoint("")).toBe(false); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.