Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/harness-credential-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sapiom/harness": minor
"@sapiom/analytics-core": minor
---

Agent Studio now sends the tenant API key through the shared credential policy: plain http to a non-loopback Sapiom host is refused (Agents and core panels, the Assistant bridge, and the MCP config written for Claude Code and Codex), and the key is dropped on a redirect to another origin. `https://` and `localhost` are unchanged. For a trusted private network, opt in with `SAPIOM_ALLOW_INSECURE_HTTP=1`.
Comment thread
antoine-berger marked this conversation as resolved.

`@sapiom/analytics-core/credential` adds an `"authorization"` credential header (for Bearer APIs) and `credentialHeaders()`, which checks a URL before a credential is handed to another process.
84 changes: 84 additions & 0 deletions packages/analytics-core/src/credential.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CredentialChannelError,
assertCredentialMayTravel,
credentialFetch,
credentialHeaders,
isLoopbackHostname,
matchesIntegrity,
mayCarryCredential,
Expand Down Expand Up @@ -380,4 +381,87 @@ describe("credentialFetch", () => {
undefined,
]);
});

describe("with an authorization credential", () => {
const BEARER = { header: "authorization", value: "Bearer k" } as const;
const redirectTo = (location: string) =>
new Response(null, { status: 307, headers: { location } });

it("replaces the caller's Authorization header", async () => {
const { calls, fetchImpl } = recorder(new Response("ok"));
await credentialFetch(
fetchImpl,
"https://api.example/x",
{ headers: { Authorization: "Bearer forged" } },
BEARER,
STRICT,
);
expect(calls[0].headers).toEqual({ authorization: "Bearer k" });
});

it("drops it on a redirect to another origin", async () => {
const { calls, fetchImpl } = recorder(
redirectTo("https://other.example/y"),
new Response("ok"),
);
await credentialFetch(
fetchImpl,
"https://api.example/x",
{},
BEARER,
STRICT,
);
expect(calls.map((c) => c.headers.authorization)).toEqual([
"Bearer k",
undefined,
]);
});

it("keeps it on a same-origin redirect", async () => {
const { calls, fetchImpl } = recorder(
redirectTo("/y"),
new Response("ok"),
);
await credentialFetch(
fetchImpl,
"https://api.example/x",
{},
BEARER,
STRICT,
);
expect(calls.map((c) => c.headers.authorization)).toEqual([
"Bearer k",
"Bearer k",
]);
});
});
});

describe("credentialHeaders", () => {
const KEY = { header: "x-api-key", value: "sk_real" } as const;

it.each([
"https://api.example/mcp",
"http://localhost:3000/mcp",
"http://127.0.0.1/mcp",
])("returns the credential header for %s", (url) => {
expect(credentialHeaders(url, KEY, STRICT)).toEqual({
"x-api-key": "sk_real",
});
});

it.each(["http://10.0.0.5:3000/mcp", "ftp://host/mcp"])(
"refuses %s",
(url) => {
expect(() => credentialHeaders(url, KEY, STRICT)).toThrow(
CredentialChannelError,
);
},
);

it("allows plain http to a private host when opted in", () => {
expect(
credentialHeaders("http://10.0.0.5:3000/mcp", KEY, OPTED_IN),
).toEqual({ "x-api-key": "sk_real" });
});
});
16 changes: 13 additions & 3 deletions packages/analytics-core/src/credential.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** Centralize channel and redirect checks so clients share one tenant-credential policy.
* Harness exceptions to the literal-header guard are tracked in SAP-4112. */
/** Centralize channel and redirect checks so clients share one tenant-credential policy. */
import { createHash } from "node:crypto";

export const ALLOW_INSECURE_HTTP_ENV = "SAPIOM_ALLOW_INSECURE_HTTP";
Expand Down Expand Up @@ -293,10 +292,21 @@ export async function fetchKeepingCredential(
}

export interface Credential {
readonly header: "x-api-key" | "x-sapiom-api-key";
readonly header: "x-api-key" | "x-sapiom-api-key" | "authorization";
readonly value: string;
}

/** For a credential handed to another process (a config file): check the URL where it is chosen,
* since the request itself is made outside this policy. */
export function credentialHeaders(
url: string,
credential: Credential,
policy: CredentialPolicy,
): Record<string, string> {
assertCredentialMayTravel(new URL(url), policy);
return { [credential.header]: credential.value };
}

/** Set the credential after caller headers so they cannot replace it.
* Apply shared channel checks and remove credentials when following origin changes. */
export async function credentialFetch(
Expand Down
14 changes: 14 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,20 @@ from what its other components do on their own (the app's product analytics, and
(`source: served` or `source: bundled`). `SAPIOM_AUTHORING_RULES_FETCH_DISABLED=1`
(or `true`) skips the request.

Requests that carry your Sapiom API key (the Agents and account panels, the
Assistant, and the MCP config written for Claude Code and Codex) only travel over
`https://` or to a loopback host (`localhost`, `127.0.0.1`), and the key is
dropped on a redirect to another origin. A custom environment on plain `http://`
to any other host (for example `http://10.0.0.5:3000`) is refused, and starting a
signed-in session against it fails (a session without an API key has no key to
protect and starts as before). On a trusted private network, opt in with
`SAPIOM_ALLOW_INSECURE_HTTP=1`:

- **CLI**: set it in the shell that runs `npx @sapiom/harness`.
- **Desktop app**: a launched app does not read your shell profile, so set it in
the session environment and reopen the app (on macOS,
`launchctl setenv SAPIOM_ALLOW_INSECURE_HTTP 1`).

## Development

```bash
Expand Down
33 changes: 30 additions & 3 deletions packages/harness/src/core/account-plan.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import { createAccountPlanReader, extractBalance, extractLimit, extractPlan } from "./account-plan.js";
import type { ApiKeyProvider } from "./api-key-provider.js";
Expand Down Expand Up @@ -142,7 +142,7 @@ describe("createAccountPlanReader", () => {
]),
);
for (const call of fetchImpl.mock.calls) {
expect((call[1] as RequestInit).headers).toEqual({ Authorization: "Bearer sk_live" });
expect((call[1] as RequestInit).headers).toEqual({ authorization: "Bearer sk_live" });
}
});

Expand Down Expand Up @@ -236,7 +236,7 @@ describe("createAccountPlanReader", () => {
it("refreshes the key once on a 401 and retries", async () => {
const keys = provider(["sk_stale", "sk_fresh"]);
const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string>).Authorization;
const auth = (init?.headers as Record<string, string>).authorization;
if (auth === "Bearer sk_stale") return jsonResponse({ error: "unauthorized" }, 401);
const url = String(input);
if (url.endsWith("/v1/dashboard/plan")) return jsonResponse(planBody());
Expand All @@ -253,3 +253,30 @@ describe("createAccountPlanReader", () => {
expect(keys.refreshCalls).toBeGreaterThanOrEqual(1);
});
});

describe("createAccountPlanReader — credential channel", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

it("refuses plaintext http to a non-loopback host and logs why", async () => {
vi.stubEnv("SAPIOM_ALLOW_INSECURE_HTTP", "");
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
const fetchImpl = vi.fn() as unknown as typeof fetch;
const reader = createAccountPlanReader({
apiKey: "sk_live",
baseUrl: "http://api.internal",
fetchImpl,
});

expect(await reader.view()).toEqual({
plan: null,
readout: { kind: "none" },
source: "fallback",
reason: "unreachable",
});
expect(fetchImpl).not.toHaveBeenCalled();
expect(String(errors.mock.calls[0]?.[0])).toContain("refusing plaintext HTTP");
});
});
14 changes: 10 additions & 4 deletions packages/harness/src/core/account-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
* never take the rail down.
*/

import { credentialFetch } from "@sapiom/analytics-core/credential";
import { harnessCredentialPolicy } from "./credential-policy.js";
import type { AccountPlanView } from "../shared/types.js";
import {
type ApiKeyProvider,
Expand Down Expand Up @@ -131,10 +133,14 @@ export function createAccountPlanReader(opts: {

const attempt = async (key: string): Promise<Response | null> => {
try {
return await fetchImpl(`${baseUrl}${path}`, {
// Core (`api.*`) takes a Bearer token — see template-catalog.ts.
headers: { Authorization: `Bearer ${key}` },
});
// Core (`api.*`) takes a Bearer token: see template-catalog.ts.
return await credentialFetch(
fetchImpl,
`${baseUrl}${path}`,
{},
{ header: "authorization", value: `Bearer ${key}` },
harnessCredentialPolicy(),
);
} catch (err) {
warnOnce(err instanceof Error ? err.message : String(err));
return null;
Expand Down
9 changes: 9 additions & 0 deletions packages/harness/src/core/credential-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {
resolveCredentialPolicy,
type CredentialPolicy,
} from "@sapiom/analytics-core/credential";

/** Per call, so the `SAPIOM_ALLOW_INSECURE_HTTP` opt-in is read when the request is made. */
export function harnessCredentialPolicy(): CredentialPolicy {
return resolveCredentialPolicy(undefined, { label: "@sapiom/harness" });
}
23 changes: 20 additions & 3 deletions packages/harness/src/core/definition-app-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Link", and the key contract (Bearer, `/v1`, one refresh on 401/403).
*/

import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { ApiKeyProvider } from "./api-key-provider.js";
import {
Expand Down Expand Up @@ -102,7 +102,7 @@ describe("createDefinitionAppLinkReader", () => {
expect(fetchImpl).toHaveBeenCalledWith(
`${BASE}/v1/workflows/definitions/886/app-link`,
expect.objectContaining({
headers: { Authorization: "Bearer sk_a" },
headers: { authorization: "Bearer sk_a" },
signal: expect.any(AbortSignal),
}),
);
Expand Down Expand Up @@ -143,7 +143,7 @@ describe("createDefinitionAppLinkReader", () => {
expect(fetchImpl).toHaveBeenLastCalledWith(
`${BASE}/v1/workflows/definitions/886/app-link`,
expect.objectContaining({
headers: { Authorization: "Bearer sk_new" },
headers: { authorization: "Bearer sk_new" },
signal: expect.any(AbortSignal),
}),
);
Expand Down Expand Up @@ -221,3 +221,20 @@ describe("createDefinitionAppLinkReader", () => {
expect(signals[0]).toBe(signals[1]);
});
});

describe("createDefinitionAppLinkReader — credential channel", () => {
afterEach(() => vi.unstubAllEnvs());

it("refuses plaintext http to a non-loopback host without calling fetch", async () => {
vi.stubEnv("SAPIOM_ALLOW_INSECURE_HTTP", "");
const fetchImpl = vi.fn() as unknown as typeof fetch;
const reader = createDefinitionAppLinkReader({
apiKey: "sk_a",
baseUrl: "http://api.internal",
fetchImpl,
});

await expect(reader.read("886")).resolves.toEqual(NO_APP_LINK);
expect(fetchImpl).not.toHaveBeenCalled();
});
});
15 changes: 10 additions & 5 deletions packages/harness/src/core/definition-app-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* A broken dashboard read must never take the session bar down.
*/

import { credentialFetch } from "@sapiom/analytics-core/credential";
import { harnessCredentialPolicy } from "./credential-policy.js";
import type { DefinitionAppLinkView } from "../shared/types.js";
import {
type ApiKeyProvider,
Expand Down Expand Up @@ -86,11 +88,14 @@ export function createDefinitionAppLinkReader(opts: {
signal: AbortSignal,
): Promise<Response | null> => {
try {
return await fetchImpl(`${baseUrl}${path}`, {
// Core (`api.*`) takes a Bearer token — see template-catalog.ts.
headers: { Authorization: `Bearer ${key}` },
signal,
});
// Core (`api.*`) takes a Bearer token: see template-catalog.ts.
return await credentialFetch(
fetchImpl,
`${baseUrl}${path}`,
{ signal },
{ header: "authorization", value: `Bearer ${key}` },
harnessCredentialPolicy(),
);
} catch {
return null;
}
Expand Down
Loading
Loading