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
55 changes: 55 additions & 0 deletions src/__tests__/s2s-verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { createHmac } from "node:crypto";
import { verifyS2sHeader } from "../s2s-verify.js";

function deriveRecipientSubkey(masterSecret: string, slug: string): string {
return createHmac("sha256", masterSecret).update(`s2s-recipient:${slug}`).digest("hex");
}
function mintHeader(secret: string, unixSeconds: number): string {
const message = `t=${unixSeconds}`;
const hex = createHmac("sha256", secret).update(message).digest("hex");
return `${message},v1=${hex}`;
}

describe("verifyS2sHeader", () => {
const MASTER = "test-master-secret-do-not-use-in-prod";
const ownSubkey = deriveRecipientSubkey(MASTER, "huntress");
const siblingSubkey = deriveRecipientSubkey(MASTER, "ironscales");

it("accepts a header minted with this vendor's own derived subkey", () => {
const now = Math.floor(Date.now() / 1000);
expect(verifyS2sHeader(mintHeader(ownSubkey, now), ownSubkey)).toBe(true);
});
it("REJECTS a header minted for a different vendor's derived subkey (recipient-binding proof)", () => {
const now = Math.floor(Date.now() / 1000);
expect(verifyS2sHeader(mintHeader(siblingSubkey, now), ownSubkey)).toBe(false);
});
it("rejects a stale timestamp outside the skew window", () => {
const now = Math.floor(Date.now() / 1000);
expect(verifyS2sHeader(mintHeader(ownSubkey, now - 301), ownSubkey)).toBe(false);
});
it("rejects a future timestamp outside the skew window", () => {
const now = Math.floor(Date.now() / 1000);
expect(verifyS2sHeader(mintHeader(ownSubkey, now + 301), ownSubkey)).toBe(false);
});
it("accepts a timestamp at the edge of the skew window", () => {
const now = Math.floor(Date.now() / 1000);
expect(verifyS2sHeader(mintHeader(ownSubkey, now - 300), ownSubkey)).toBe(true);
});
it("rejects a malformed header value", () => {
expect(verifyS2sHeader("not-a-valid-header", ownSubkey)).toBe(false);
});
it("rejects a missing header", () => {
expect(verifyS2sHeader(undefined, ownSubkey)).toBe(false);
});
it("rejects when the secret is empty (dark-by-default guarantee)", () => {
const now = Math.floor(Date.now() / 1000);
expect(verifyS2sHeader(mintHeader(ownSubkey, now), "")).toBe(false);
});
it("rejects a tampered signature", () => {
const now = Math.floor(Date.now() / 1000);
const header = mintHeader(ownSubkey, now);
const tampered = header.slice(0, -1) + (header.endsWith("0") ? "1" : "0");
expect(verifyS2sHeader(tampered, ownSubkey)).toBe(false);
});
});
13 changes: 13 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { createServer } from './server.js';
import { getCredentials, runWithCredentials } from './utils/client.js';
import { logger } from './utils/logger.js';
import { verifyS2sHeader, S2S_HEADER } from './s2s-verify.js';

const S2S_SECRET = process.env.CONDUIT_S2S_SECRET || '';

function startHttpServer(): void {
const port = parseInt(process.env.MCP_HTTP_PORT || '8080', 10);
Expand Down Expand Up @@ -41,6 +44,16 @@ function startHttpServer(): void {
return;
}

if (S2S_SECRET && !verifyS2sHeader(req.headers[S2S_HEADER] as string | undefined, S2S_SECRET)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
error: 'Missing or invalid X-Gateway-S2S header: this endpoint only accepts requests signed by the gateway.',
})
);
return;
}

const apiKey = isGatewayMode ? (req.headers['x-huntress-api-key'] as string | undefined) : undefined;
const apiSecret = isGatewayMode ? (req.headers['x-huntress-api-secret'] as string | undefined) : undefined;

Expand Down
43 changes: 43 additions & 0 deletions src/s2s-verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Verifies the conduit gateway's service-to-service auth header
* (gateway#377 parity — closes the confused-deputy gap where a compromised
* sibling sidecar in the shared ACA environment could otherwise impersonate
* the gateway to this container).
*
* This is a near-verbatim port of conduit's `verifyS2sHeader`
* (src/proxy/s2s.ts) — intentionally unchanged logic, ported once and kept
* in sync. `secret` is treated as an opaque value: since gateway#377
* Finding B, the gateway derives a per-vendor subkey from its master
* secret and this container is provisioned (via CONDUIT_S2S_SECRET) with
* only its own derived value, never the raw master — a sibling sidecar
* holds a DIFFERENT derived value and cannot forge a header that verifies
* here. This function doesn't need to know that; it just checks whatever
* secret it's handed.
*
* Empty secret => always returns false. The caller is expected to treat an
* empty CONDUIT_S2S_SECRET as "S2S enforcement disabled" (dark-by-default,
* matches the dormant/pre-provisioning state) rather than calling this at
* all — see the enforcement check in http.ts.
*/
import { createHmac, timingSafeEqual } from "node:crypto";

/** Request header carrying the S2S proof. Node lowercases incoming header names. */
export const S2S_HEADER = "x-gateway-s2s";

const HEADER_VALUE_RE = /^t=(\d{1,15}),v1=([0-9a-f]{64})$/;

export function verifyS2sHeader(
headerValue: string | undefined,
secret: string,
maxSkewSeconds = 300
): boolean {
if (!secret || !headerValue) return false;
const match = HEADER_VALUE_RE.exec(headerValue);
if (!match) return false;
const t = Number(match[1]);
if (!Number.isSafeInteger(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > maxSkewSeconds) return false;
const expected = createHmac("sha256", secret).update(`t=${t}`).digest();
const provided = Buffer.from(match[2], "hex");
return provided.length === expected.length && timingSafeEqual(provided, expected);
}
Loading