8000 feat(transport): verify a signed tenant context on every request by Mnehmos · Pull Request #82 · Mnehmos/mnehmos.rpg.mcp · GitHub
[go: up one dir, main page]

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
93 changes: 80 additions & 13 deletions src/server/transport/http.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,61 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { runInTenant, type TenantContext } from '../../storage/tenant-context.js';
import { verifyTenantToken } from './tenant-token.js';

export interface HttpServerTransportOptions {
host?: string;
authToken?: string;
tenantSecret?: string;
maxBodyBytes?: number;
}

const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;

function isAuthorized(req: IncomingMessage, authToken: string | undefined): boolean {
if (!authToken) return true;
const url = new URL(req.url || '/', 'http://localhost');
const queryToken = url.searchParams.get('token');
/**
* Service authentication only: proves the caller is the web service, says
* nothing about which customer the request is for. Tenant identity comes from
* the separate signed x-rpg-tenant header.
*
* The token is deliberately NOT accepted from the query string. Query
* parameters are recorded by proxies, CDNs, and access logs, so a credential
* placed there leaks into infrastructure that has no business holding it.
*/
function isAuthorized(req: IncomingMessage, authToken: string): boolean {
const headerToken = req.headers['x-rpg-mcp-token'];
const authHeader = req.headers.authorization;
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
return queryToken === authToken || headerToken === authToken || bearerToken === authToken;
return headerToken === authToken || bearerToken === authToken;
}

/**
* Resolves the tenant a request is acting for.
*
* Absent header is allowed and yields no tenant: meta-tools (search_tools,
* load_tool_schema) are genuinely tenant-agnostic and the host's tool catalog
* loads schemas before any campaign is in play. Those requests simply never
* reach `requireTenant()`. Anything that touches tenant-owned storage fails
* closed there instead, which keeps the boundary at the database rather than
* at a transport rule that would have to enumerate which tools are safe.
*
* A header that is *present but invalid* is always rejected — that is a caller
* asserting an identity it cannot prove, which is never a request we want to
* serve unscoped.
*/
function resolveTenant(
req: IncomingMessage,
tenantSecret: string | undefined
): { ok: true; context?: TenantContext } | { ok: false; reason: string } {
const raw = req.headers['x-rpg-tenant'];
if (raw === undefined) return { ok: true };

const token = Array.isArray(raw) ? raw[0] : raw;
if (!tenantSecret) return { ok: false, reason: 'no_tenant_secret_configured' };

const result = verifyTenantToken(token ?? '', tenantSecret);
if (!result.ok) return { ok: false, reason: result.reason };
return { ok: true, context: result.context };
}

function readBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
Expand Down Expand Up @@ -73,10 +111,22 @@ export async function startHttpServerTransport(
// <service>.railway.internal, so peer services can't reach it privately.
const host = options.host ?? '::';
const authToken = options.authToken ?? process.env.RPG_MCP_TRANSPORT_TOKEN;
const tenantSecret = options.tenantSecret ?? process.env.RPG_MCP_TENANT_SECRET;
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;

// Fail closed rather than warn. Serving /mcp unauthenticated exposes every
// campaign in the database to anyone who finds the URL; a service that
// cannot authenticate its caller should not accept traffic at all.
if (!authToken) {
console.error('[HTTP] WARNING: no RPG_MCP_TRANSPORT_TOKEN configured; /mcp is unauthenticated.');
throw new Error(
'RPG_MCP_TRANSPORT_TOKEN is not set. Refusing to start an unauthenticated /mcp endpoint.'
);
}
if (!tenantSecret) {
console.error(
'[HTTP] WARNING: no RPG_MCP_TENANT_SECRET configured; ' +
'tenant-scoped requests will be rejected and only tenant-agnostic meta-tools will work.'
);
}

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
Expand Down Expand Up @@ -118,15 +168,32 @@ export async function startHttpServerTransport(
return;
}

const tenant = resolveTenant(req, tenantSecret);
if (!tenant.ok) {
// Log the precise reason; return an opaque error. Telling a caller
// whether a token was expired vs. forged helps an attacker more
// than it helps a legitimate client.
console.error(`[HTTP] Rejected tenant context: ${tenant.reason}`);
res.writeHead(401, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'invalid_tenant_context' }));
return;
}

readBody(req, maxBodyBytes)
.then(async (body) => {
const mcpServer = serverFactory();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => {
void transport.close();
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res, body);
const handle = async () => {
const mcpServer = serverFactory();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => {
void transport.close();
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res, body);
};
// The tenant scope must wrap the entire handler subtree, not
// just the dispatch, so every awaited continuation inside a
// tool handler still resolves to this request's tenant.
return tenant.context ? runInTenant(tenant.context, handle) : handle();
})
.catch((error: Error) => {
console.error('[HTTP] Request failed:', error.message);
Expand Down
112 changes: 112 additions & 0 deletions src/server/transport/tenant-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { TenantContext } from '../../storage/tenant-context.js';

/**
* Verification for the signed tenant context the web host sends as
* `x-rpg-tenant`.
*
* Wire format (must stay byte-identical to the host's signer):
*
* base64url(payloadJson) + "." + base64url(hmacSha256(secret, base64url(payloadJson)))
*
* The HMAC covers the *encoded* payload segment, not the decoded JSON, so
* verification never has to re-serialize and can't be broken by key ordering
* or whitespace differences between the two runtimes.
*
* This token answers "which customer is this request for". The separate
* RPG_MCP_TRANSPORT_TOKEN answers "is this the web service at all". Conflating
* them is what left the engine unable to distinguish tenants in the first
* place, so they are deliberately different secrets with different lifetimes.
*/

export interface TenantTokenPayload {
accountId: string;
campaignId: string;
worldId?: string;
partyId?: string;
/** Issued-at, seconds since epoch. */
iat: number;
/** Expiry, seconds since epoch. */
exp: number;
}

export type TenantTokenResult =
| { ok: true; context: TenantContext }
| { ok: false; reason: string };

/**
* Small tolerance for clock drift between the web service and the engine.
* Without it, a token minted on a host whose clock runs slightly fast is
* rejected as not-yet-valid and the request fails for no real reason.
*/
const CLOCK_SKEW_SECONDS = 60;

function base64urlDecode(segment: string): Buffer {
return Buffer.from(segment, 'base64url');
}

function expectedSignature(secret: string, encodedPayload: string): Buffer {
return createHmac('sha256', secret).update(encodedPayload).digest();
}

/**
* Verifies a tenant token and returns the tenant it names.
*
* Returns a reason rather than throwing so the transport can log precisely why
* a token failed while still returning an opaque 401 to the caller — the
* distinction between "bad signature" and "expired" is useful in our logs and
* useful to an attacker, so it stays on our side of the boundary.
*/
export function verifyTenantToken(
token: string,
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000)
): TenantTokenResult {
if (!secret) return { ok: false, reason: 'no_secret_configured' };
if (!token) return { ok: false, reason: 'empty_token' };

const parts = token.split('.');
if (parts.length !== 2) return { ok: false, reason: 'malformed_token' };

const [encodedPayload, encodedSignature] = parts;
if (!encodedPayload || !encodedSignature) return { ok: false, reason: 'malformed_token' };

const provided = base64urlDecode(encodedSignature);
const expected = expectedSignature(secret, encodedPayload);
// timingSafeEqual throws on length mismatch, so guard before comparing.
if (provided.length !== expected.length) return { ok: false, reason: 'bad_signature' };
if (!timingSafeEqual(provided, expected)) return { ok: false, reason: 'bad_signature' };

let payload: TenantTokenPayload;
try {
payload = JSON.parse(base64urlDecode(encodedPayload).toString('utf8')) as TenantTokenPayload;
} catch {
return { ok: false, reason: 'undecodable_payload' };
}

if (typeof payload?.accountId !== 'string' || payload.accountId.length === 0) {
return { ok: false, reason: 'missing_account_id' };
}
if (typeof payload?.campaignId !== 'string' || payload.campaignId.length === 0) {
return { ok: false, reason: 'missing_campaign_id' };
}
if (typeof payload? 8000 .exp !== 'number' || !Number.isFinite(payload.exp)) {
return { ok: false, reason: 'missing_exp' };
}
if (payload.exp + CLOCK_SKEW_SECONDS < nowSeconds) {
return { ok: false, reason: 'expired' };
}
if (typeof payload.iat === 'number' && payload.iat - CLOCK_SKEW_SECONDS > nowSeconds) {
return { ok: false, reason: 'issued_in_future' };
}

return {
ok: true,
context: {
accountId: payload.accountId,
campaignId: payload.campaignId,
worldId: typeof payload.worldId === 'string' ? payload.worldId : undefined,
partyId: typeof payload.partyId === 'string' ? payload.partyId : undefined,
},
};
}
57 changes: 57 additions & 0 deletions src/storage/tenant-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AsyncLocalStorage } from 'node:async_hooks';

/**
* The tenant a request is acting on behalf of.
*
* This is derived exclusively from the signed `x-rpg-tenant` header verified at
* the HTTP transport — never from tool arguments. Model-supplied arguments are
* untrusted input: a prompt-injected or hallucinated campaign id must not be
* able to select which database this process opens.
*/
export interface TenantContext {
accountId: string;
campaignId: string;
/** Optional scope hints carried for logging/diagnostics; not used for routing. */
worldId?: string;
partyId?: string;
}

/**
* AsyncLocalStorage rather than a module-level variable is load-bearing.
*
* Tool handlers are async and concurrent requests interleave at every `await`.
* A plain "current tenant" variable would let one campaign's continuation
* resume while another campaign's value is installed, handing the first
* campaign the second one's database — precisely the cross-tenant bug this
* module exists to prevent.
*/
const storage = new AsyncLocalStorage<TenantContext>();

/** Runs `fn` with `context` as the ambient tenant for the whole async subtree. */
export function runInTenant<T>(context: TenantContext, fn: () => T): T {
return storage.run(context, fn);
}

/** The ambient tenant, or undefined outside any tenant scope. */
export function getTenant(): TenantContext | undefined {
return storage.getStore();
}

/**
* The ambient tenant, or a thrown error.
*
* Fails closed by design: callers that need tenant-owned storage must not be
* able to proceed without a verified tenant. Meta-tools that touch no database
* (search_tools, load_tool_schema) never call this and so keep working on
* unscoped requests.
*/
export function requireTenant(): TenantContext {
const context = storage.getStore();
if (!context) {
throw new Error(
'No tenant context in scope. This request did not carry a verified x-rpg-tenant header, ' +
'so tenant-owned storage cannot be opened.'
);
}
return context;
}
Loading
Loading
0