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
12 changes: 9 additions & 3 deletions packages/dcp-core/src/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,15 @@ export class BudgetEngine {
if (amount < 0) {
throw new VaultError('INTERNAL_ERROR', 'Budget limit cannot be negative');
}
// Guard against prototype-polluting currency keys (defensive — currency is a
// free-form string).
if (currency === '__proto__' || currency === 'constructor' || currency === 'prototype') {
// Guard both index keys against prototype pollution. `type` is a typed union
// but unchecked at runtime; `currency` is a free-form string. Reject the
// dangerous keys on BOTH so neither `config[type]` nor `[currency]` can reach
// Object.prototype.
const DANGEROUS = ['__proto__', 'constructor', 'prototype'];
if (type !== 'daily_budget' && type !== 'tx_limit' && type !== 'approval_threshold') {
throw new VaultError('INTERNAL_ERROR', 'Invalid budget type');
}
if (DANGEROUS.includes(currency)) {
throw new VaultError('INTERNAL_ERROR', 'Invalid currency');
}

Expand Down
11 changes: 6 additions & 5 deletions packages/dcp-relay/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,21 @@
"author": "DCP Protocol",
"license": "Apache-2.0",
"dependencies": {
"fastify": "^5.2.1",
"@fastify/websocket": "^11.0.1",
"@fastify/cors": "^10.0.2",
"@fastify/rate-limit": "^10.0.0",
"@fastify/websocket": "^11.0.1",
"@noble/curves": "^1.4.0",
"pino-pretty": "^13.0.0",
"jose": "^5.9.6"
"fastify": "^5.2.1",
"jose": "^5.9.6",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"vite": "^6.0.0",
"@types/node": "^22.10.2",
"@types/ws": "^8.5.13",
"tsup": "^8.3.5",
"tsx": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.0",
"vitest": "^3.0.0",
"ws": "^8.18.0"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/dcp-relay/src/oauth/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* Pure logic: returns { status, body }. The HTTP layer just (de)serializes.
*/

import { stripTrailingSlashes } from '../safe-url.js';
import type { RelayOAuthKeys } from './keys.js';
import type { AuthSessionStore, RefreshTokenStore } from './store.js';
import type { JtiReplayGuard } from './dpop.js';
Expand Down Expand Up @@ -48,7 +49,7 @@ const oauthError = (status: number, error: string, description?: string): GrantR
});

function resourceFor(issuer: string, vaultId: string): string {
return `${issuer.replace(/\/+$/, '')}/v/${vaultId}/mcp`;
return `${stripTrailingSlashes(issuer)}/v/${vaultId}/mcp`;
}

/** Call the optional link-less pairing bridge, normalising "not wired" to an error. */
Expand Down
4 changes: 3 additions & 1 deletion packages/dcp-relay/src/oauth/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
* DPoP, and bearer methods. No implicit/password/plain — OAuth 2.1 hardening.
*/

import { stripTrailingSlashes } from '../safe-url.js';

export interface AsMetadataOptions {
/** Public base URL of the relay (the OAuth issuer), e.g. https://relay.example. No trailing slash. */
issuer: string;
scopesSupported?: string[];
}

export function authorizationServerMetadata(opts: AsMetadataOptions): Record<string, unknown> {
const base = opts.issuer.replace(/\/+$/, '');
const base = stripTrailingSlashes(opts.issuer);
return {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
Expand Down
12 changes: 10 additions & 2 deletions packages/dcp-relay/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import type { FastifyPluginCallback, FastifyPluginOptions, RouteShorthandOptions } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyCors from '@fastify/cors';
import fastifyRateLimit from '@fastify/rate-limit';
import { stripTrailingSlashes, jsonForScript } from './safe-url.js';
import type { WebSocket } from 'ws';
import { randomUUID } from 'crypto';
import { ed25519 } from '@noble/curves/ed25519';
Expand Down Expand Up @@ -211,6 +213,12 @@ export class RelayServer {
fastifyCors as unknown as FastifyPluginCallback<FastifyPluginOptions>,
{ origin: true }
);
// Global rate limit on the public HTTP surface (defense-in-depth; the WS
// control channel and per-route guards still apply).
await this.server.register(
fastifyRateLimit as unknown as FastifyPluginCallback<FastifyPluginOptions>,
{ max: 300, timeWindow: '1 minute' }
);
await this.server.register(
fastifyWebsocket as unknown as FastifyPluginCallback<FastifyPluginOptions>
);
Expand Down Expand Up @@ -523,7 +531,7 @@ export class RelayServer {
* publicUrl; otherwise derives from the request (dev/local). No trailing slash.
*/
private baseUrl(request: FastifyRequest): string {
if (this.config.publicUrl) return this.config.publicUrl.replace(/\/+$/, '');
if (this.config.publicUrl) return stripTrailingSlashes(this.config.publicUrl);
const proto = (request.headers['x-forwarded-proto'] as string) || request.protocol || 'http';
const host = request.headers['host'] || `${this.config.host}:${this.config.port}`;
return `${proto}://${host}`;
Expand Down Expand Up @@ -770,7 +778,7 @@ export class RelayServer {
`<p class="muted">Confirm this match code matches the one shown on your device, then approve there.</p>` +
`<div class="code">${esc(result.matchCode)}</div>` +
`<p class="muted" id="s">Waiting for approval…</p>` +
`<script>(function(){var c=${JSON.stringify(cfg)};` +
`<script>(function(){var c=${jsonForScript(cfg)};` +
`function poll(){fetch('/oauth/authorize/status?session='+encodeURIComponent(c.sessionId))` +
`.then(function(r){return r.json()}).then(function(d){` +
`if(d.status==='approved'){var u=new URL(c.redirectUri);u.searchParams.set('code',d.code);` +
Expand Down
30 changes: 30 additions & 0 deletions packages/dcp-relay/src/safe-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Small string-safety helpers for the relay's public HTTP surface.
*/

/**
* Strip trailing '/' characters without a backtracking regex.
*
* Replaces `s.replace(/\/+$/, '')`, which CodeQL flags as polynomial-ReDoS on
* inputs with many '/'. This is a simple linear scan.
*/
export function stripTrailingSlashes(s: string): string {
let end = s.length;
while (end > 0 && s.charCodeAt(end - 1) === 47 /* '/' */) end--;
return s.slice(0, end);
}

/**
* Serialize a value for safe embedding inside an inline <script>...</script>.
*
* JSON.stringify alone is NOT safe in a script context: a value containing
* `</script>` can break out of the tag and inject markup. Escaping `<`, `>` and
* `&` to their \uXXXX forms keeps the payload a valid JS string while making
* tag-breakout impossible.
*/
export function jsonForScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
9 changes: 9 additions & 0 deletions packages/dcp-vault/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import Fastify, { FastifyInstance, FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import {
VaultStorage,
BudgetEngine,
Expand Down Expand Up @@ -1106,6 +1107,14 @@ async function buildServer(): Promise<FastifyInstance> {
methods: ['GET', 'POST', 'DELETE', 'PATCH'],
});

// Global rate limit. The vault server binds to localhost, but this is cheap
// defense-in-depth against a runaway/compromised local agent hammering routes.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await server.register(rateLimit as any, {
max: 600,
timeWindow: '1 minute',
});

// Initialize vault storage (respects VAULT_DIR env for testing)
const vaultDir = process.env.VAULT_DIR;
storage = getStorage(vaultDir);
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading