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
5 changes: 5 additions & 0 deletions .changeset/harden-challenge-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Hardened server secret-key validation and capped oversized `WWW-Authenticate` request parameters.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generate with: openssl rand -base64 32
MPP_SECRET_KEY=

# Examples
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export async function handler(request: Request) {
}
```

Generate `MPP_SECRET_KEY` with at least 32 bytes, for example: `openssl rand -base64 32`.

### Client

```ts
Expand Down Expand Up @@ -159,13 +161,13 @@ export const POST = proxy.fetch // Next.js

This exposes the following routes:

| Route | Pricing |
| ---------------------------------- | ---------------------------- |
| `POST /openai/v1/chat/completions` | charge **$0.005** |
| Route | Pricing |
| ---------------------------------- | ----------------------------- |
| `POST /openai/v1/chat/completions` | charge **$0.005** |
| `POST /openai/v1/completions` | session **$0.0001 per token** |
| `GET /openai/v1/models` | free |
| `POST /stripe/v1/charges` | charge **$0.01** |
| `GET /stripe/v1/customers/:id` | free |
| `GET /openai/v1/models` | free |
| `POST /stripe/v1/charges` | charge **$0.01** |
| `GET /stripe/v1/customers/:id` | free |

## Protocol

Expand Down
2 changes: 1 addition & 1 deletion examples/charge-wagmi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const mppx = Mppx.create({
testnet: true,
}),
],
secretKey: 'test',
secretKey: 'test-secret-key-test-secret-key-32',
})

export async function handler(request: Request): Promise<Response | null> {
Expand Down
2 changes: 1 addition & 1 deletion examples/session/sse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const store = Store.memory()
// `Mppx.create()` requires a secret key so challenge IDs can be verified
// statelessly. The example ships with a default demo key so `pnpm dev` works
// out of the box, but still allows override via `MPP_SECRET_KEY`.
const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-sse-secret'
const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-sse-secret-key-minimum-32'

//
// `Mppx.create()` assembles the payment handler from method intents.
Expand Down
2 changes: 1 addition & 1 deletion examples/session/ws/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const pricePerToken = '0.000075'
// `Mppx.create()` requires a secret key so challenge IDs can be verified
// statelessly. The example ships with a default demo key so `pnpm dev` works
// out of the box, but still allows override via `MPP_SECRET_KEY`.
const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-websocket-secret'
const secretKey = process.env.MPP_SECRET_KEY ?? 'mppx-demo-websocket-secret-key-32'

// Viem Client

Expand Down
2 changes: 1 addition & 1 deletion examples/x402-mpp/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function createApp(options: AppOptions) {
x402: { facilitator },
}),
],
secretKey: options.secretKey ?? 'x402-mpp-example',
secretKey: options.secretKey ?? 'x402-mpp-example-secret-key-min-32',
})

const paid = payments.compose(
Expand Down
7 changes: 7 additions & 0 deletions src/Challenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,13 @@ describe('deserialize', () => {
),
).toThrow('Missing request parameter.')
})

test('error: rejects oversized request parameter before decoding', () => {
const oversizedRequest = 'a'.repeat(16 * 1024 + 1)
const header = `Payment id="a", realm="api", method="tempo", intent="charge", request="${oversizedRequest}"`

expect(() => Challenge.deserialize(header)).toThrow('Request parameter too large.')
})
})

describe('fromHeaders', () => {
Expand Down
21 changes: 17 additions & 4 deletions src/Challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type * as Method from './Method.js'
import * as PaymentRequest from './PaymentRequest.js'
import * as z from './zod.js'

const maxRequestParameterLength = 16 * 1024

/**
* Schema for a payment challenge.
*
Expand Down Expand Up @@ -347,6 +349,7 @@ export function deserialize<const methods extends readonly Method.Method[] | und

const { request, opaque, ...rest } = result
if (!request) throw new Error('Missing request parameter.')
if (request.length > maxRequestParameterLength) throw new Error('Request parameter too large.')
if (rest.method && !/^[a-z][a-z0-9:_-]*$/.test(rest.method))
throw new Error(`Invalid method: "${rest.method}". Must be lowercase per spec.`)

Expand Down Expand Up @@ -441,26 +444,36 @@ function readQuotedAuthParamValue(
start: number,
): [value: string, nextIndex: number] {
let i = start
let value = ''
let escaped = false
let segmentStart = start
let parts: string[] | undefined

while (i < input.length) {
const char = input[i]!
i++

if (escaped) {
value += char
parts ??= []
parts.push(char)
segmentStart = i
escaped = false
continue
}

if (char === '\\') {
parts ??= []
parts.push(input.slice(segmentStart, i - 1))
segmentStart = i
escaped = true
continue
}

if (char === '"') return [value, i]
value += char
if (char === '"') {
const value = parts
? [...parts, input.slice(segmentStart, i - 1)].join('')
: input.slice(start, i - 1)
return [value, i]
}
}

throw new Error('Unterminated quoted-string.')
Expand Down
30 changes: 15 additions & 15 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ describe('basic charge (examples/basic)', () => {
const server = Mppx_server.create({
methods: [tempo.charge({ getClient: () => client })],
realm: 'cli-test-basic',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -484,7 +484,7 @@ describe('basic charge (examples/basic)', () => {
const server = Mppx_server.create({
methods: [unsupportedMethod, tempoMethod],
realm: 'cli-test-multi-offer',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -563,7 +563,7 @@ describe('basic charge (examples/basic)', () => {
const server = Mppx_server.create({
methods: [betaMethod, alphaMethod],
realm: 'cli-test-config-offers',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-config-'))
Expand Down Expand Up @@ -667,7 +667,7 @@ export default defineConfig({
const server = Mppx_server.create({
methods: [tempo.charge({ getClient: () => client })],
realm: 'localhost',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})
let authorization: string | undefined

Expand Down Expand Up @@ -721,7 +721,7 @@ export default defineConfig({
}),
],
realm: 'localhost',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})
let authorization: string | undefined

Expand Down Expand Up @@ -765,7 +765,7 @@ export default defineConfig({
const server = Mppx_server.create({
methods: [tempo.charge({ getClient: () => client })],
realm: 'cli-test-no-account',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -816,7 +816,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
}),
],
realm: 'cli-test-multifetch',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -938,7 +938,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
}),
],
realm: 'cli-test-double-charge',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

// Track voucher cumulative amounts from credential payloads
Expand Down Expand Up @@ -1001,7 +1001,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
}),
],
realm: 'cli-test-close-action',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

// Track the credential payload action from the close request
Expand Down Expand Up @@ -1079,7 +1079,7 @@ describe('session sse (examples/session/sse)', () => {
}),
],
realm: 'cli-test-sse',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -1146,7 +1146,7 @@ describe('stripe charge', () => {
}),
],
realm: 'cli-test-stripe',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const sptServer = await Http.createServer(async (_req, res) => {
Expand Down Expand Up @@ -1186,7 +1186,7 @@ describe('stripe charge', () => {
}),
],
realm: 'cli-test-stripe-nokey',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const appServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -1219,7 +1219,7 @@ describe('stripe charge', () => {
}),
],
realm: 'cli-test-stripe-live',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const appServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -1670,7 +1670,7 @@ export default defineConfig({
const server = Mppx_server.create({
methods: [tempo.charge({ getClient: () => client })],
realm: 'cli-sign-zero',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down Expand Up @@ -1736,7 +1736,7 @@ export default defineConfig({
}),
],
realm: 'cli-sign-zero-testnet',
secretKey: 'cli-test-secret',
secretKey: 'cli-test-secret-cli-test-secret-32',
})

const httpServer = await Http.createServer(async (req, res) => {
Expand Down
2 changes: 1 addition & 1 deletion src/client/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as Http from '~test/Http.js'
import { accounts, asset, client } from '~test/tempo/viem.js'

const realm = 'api.example.com'
const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

afterEach(() => {
Mppx.restore()
Expand Down
2 changes: 1 addition & 1 deletion src/client/internal/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
import * as Fetch from './Fetch.js'

const realm = 'api.example.com'
const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

const server = Mppx_server.create({
methods: [
Expand Down
2 changes: 1 addition & 1 deletion src/discovery/OpenApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function createMppx<const methods extends Mppx.Methods>(methods: methods) {
return Mppx.create({
methods,
realm: 'test-realm',
secretKey: 'test-secret',
secretKey: 'test-secret-key-test-secret-key-32',
})
}

Expand Down
2 changes: 1 addition & 1 deletion src/evm/PublicInterface.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Mppx as ServerMppx } from '../server/index.js'

const account = {} as Account
const recipient = '0x209693Bc6afc0C5328bA36FaF03C514EF312287C'
const secretKey = 'test-secret'
const secretKey = 'test-secret-key-test-secret-key-32'
const settle = async () => ({
reference: `0x${'1'.repeat(64)}` as `0x${string}`,
})
Expand Down
2 changes: 1 addition & 1 deletion src/evm/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('evm charge server', () => {
},
}),
],
secretKey: 'test-secret',
secretKey: 'test-secret-key-test-secret-key-32',
})
const client = ClientMppx.create({
methods: [
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/McpClient.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import * as McpServer_transport from '../server/Transport.js'
import * as McpClient from './McpClient.js'

const realm = 'api.example.com'
const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'
const chargeAmountRaw = 1_000_000n
const doubleSessionAmountRaw = chargeAmountRaw * 2n
const topUpAmountRaw = chargeAmountRaw * 3n
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/McpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as McpServer_transport from '../server/Transport.js'
import * as McpClient from './McpClient.js'

const realm = 'api.example.com'
const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

function createChallenge() {
return Challenge.fromMethod(Methods.charge, {
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/elysia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function createServer(app: Elysia<any, any, any, any, any, any, any>) {
})
}

const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

describe('payment', () => {
test('short-circuits management responses', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function createServer(app: express.Express) {
})
}

const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

function createChargeHarness(feePayer: boolean) {
const mppx = Mppx.create({
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function createServer(app: Hono) {
})
}

const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

describe('payment', () => {
test('short-circuits management responses', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/internal/mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, expect, test } from 'vp/test'
import { wrap } from './mppx.js'

const realm = 'api.example.com'
const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

const mockChargeA = Method.from({
name: 'alpha',
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/nextjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function createServer(handler: (request: Request) => Promise<Response> | Respons
})
}

const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'

describe('payment', () => {
test('short-circuits management responses', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/proxy/Proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import * as Service from './Service.js'
import { anthropic } from './services/anthropic.js'
import { openai } from './services/openai.js'

const secretKey = 'test-secret-key'
const secretKey = 'test-secret-key-test-secret-key-32'
const isLocalnet = tempoNetwork === 'localnet'

const mppx_server = Mppx_server.create({
Expand Down
Loading
Loading