Skip to content

Commit 4282fb6

Browse files
committed
fix(onramper): sign validated single-use checkout intents
1 parent b99ef4b commit 4282fb6

15 files changed

Lines changed: 559 additions & 4 deletions

File tree

‎apps/user-service/.env.example‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,14 @@ SERVICE_API_KEY=
77
DATABASE_URL="postgresql://postgres:password@db:5432/microservices"
88

99
# Service URLs
10-
SWAP_SERVICE_URL="http://swap-service:3003"
10+
SWAP_SERVICE_URL="http://swap-service:3003"
11+
# Server-only Onramper V2 configuration. Register the public key with Onramper.
12+
ONRAMPER_API_KEY=
13+
ONRAMPER_PRIVATE_KEY=
14+
ONRAMPER_API_URL=https://api.onramper.com/
15+
ONRAMPER_WIDGET_URL=https://buy.onramper.com/
16+
ONRAMPER_ALLOWED_ORIGINS=https://app.shapeshift.com
17+
ONRAMPER_REDIRECT_ORIGINS=https://app.shapeshift.com
18+
19+
# Exact socket-peer IPs allowed to supply X-Forwarded-For (empty ignores forwarded headers).
20+
TRUSTED_PROXY_IPS=

‎apps/user-service/src/app.module.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Module } from '@nestjs/common'
22
import { ConfigModule } from '@nestjs/config'
33

4+
import { SecurityRateLimiter } from '@shapeshift/shared-utils'
5+
6+
import { OnramperCatalogue, OnramperController } from './onramper/onramper.controller'
47
import { PrismaService } from './prisma/prisma.service'
58
import { ReferralController } from './referral/referral.controller'
69
import { ReferralService } from './referral/referral.service'
@@ -14,7 +17,14 @@ import { UsersService } from './users/users.service'
1417
envFilePath: ['.env', '../../.env'],
1518
}),
1619
],
17-
controllers: [UsersController, ReferralController],
18-
providers: [UsersService, ReferralService, PrismaService],
20+
controllers: [UsersController, ReferralController, OnramperController],
21+
providers: [
22+
UsersService,
23+
ReferralService,
24+
PrismaService,
25+
OnramperCatalogue,
26+
SecurityRateLimiter,
27+
{ provide: 'SECURITY_DB', useExisting: PrismaService },
28+
],
1929
})
2030
export class AppModule {}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import 'reflect-metadata'
2+
3+
import { generateKeyPairSync } from 'node:crypto'
4+
import type { INestApplication } from '@nestjs/common'
5+
import { HttpException } from '@nestjs/common'
6+
import { Test } from '@nestjs/testing'
7+
8+
import { SecurityRateLimiter } from '@shapeshift/shared-utils'
9+
10+
import { PrismaService } from '../prisma/prisma.service'
11+
12+
import { OnramperCatalogue, OnramperController } from './onramper.controller'
13+
14+
jest.mock('@shapeshiftoss/chain-adapters', () => ({ bnOrZero: jest.fn() }))
15+
16+
const intents = new Map<string, { id: string; tokenHash: string; origin: string; checkout: unknown; expiresAt: Date }>()
17+
const db = {
18+
onramperIntent: {
19+
create: ({ data }: { data: typeof intents extends Map<string, infer V> ? V : never }) => {
20+
intents.set(data.id, data)
21+
return data
22+
},
23+
findUnique: ({ where }: { where: { id: string } }) => intents.get(where.id),
24+
deleteMany: ({ where }: { where: { id?: string } }) => ({ count: where.id ? Number(intents.delete(where.id)) : 0 }),
25+
},
26+
}
27+
const consume = jest.fn()
28+
const catalogue = {
29+
get: jest.fn(() => Promise.resolve({ message: { crypto: [{ id: 'eth' }], fiat: [{ code: 'USD' }] } })),
30+
}
31+
let app: INestApplication
32+
let base: string
33+
const origin = 'https://app.shapeshift.com'
34+
const checkout = {
35+
crypto: 'eth',
36+
address: '0x' + '11'.repeat(20),
37+
fiat: 'USD',
38+
action: 'buy',
39+
amount: '100',
40+
language: 'en',
41+
theme: 'light',
42+
}
43+
const post = (path: string, body: unknown, requestOrigin = origin) =>
44+
fetch(base + path, {
45+
method: 'POST',
46+
headers: { 'content-type': 'application/json', origin: requestOrigin },
47+
body: JSON.stringify(body),
48+
})
49+
50+
beforeAll(async () => {
51+
process.env.ONRAMPER_ALLOWED_ORIGINS = origin
52+
process.env.ONRAMPER_API_KEY = 'test-integration'
53+
process.env.ONRAMPER_PRIVATE_KEY = generateKeyPairSync('ed25519')
54+
.privateKey.export({ type: 'pkcs8', format: 'pem' })
55+
.toString()
56+
const module = await Test.createTestingModule({
57+
controllers: [OnramperController],
58+
providers: [
59+
{ provide: SecurityRateLimiter, useValue: { consume } },
60+
{ provide: OnramperCatalogue, useValue: catalogue },
61+
{ provide: PrismaService, useValue: db },
62+
],
63+
}).compile()
64+
app = module.createNestApplication()
65+
await app.listen(0, '127.0.0.1')
66+
base = await app.getUrl()
67+
})
68+
afterAll(async () => app?.close())
69+
beforeEach(() => {
70+
intents.clear()
71+
consume.mockReset()
72+
catalogue.get.mockClear()
73+
})
74+
async function intent() {
75+
const response = await post('/onramper/intents', checkout)
76+
expect(response.status).toBe(201)
77+
return response.json() as Promise<{ id: string; token: string }>
78+
}
79+
80+
test('guest checkout preserves functionality without a wallet login or secret exposure', async () => {
81+
const issued = await intent()
82+
expect(intents.get(issued.id)?.tokenHash).not.toBe(issued.token)
83+
const response = await post(`/onramper/intents/${issued.id}/checkout`, { token: issued.token })
84+
expect(response.status).toBe(201)
85+
const data = (await response.json()) as { url: string }
86+
expect(data.url).toContain('sigV2=')
87+
expect(data.url).not.toContain('PRIVATE')
88+
})
89+
90+
test('the server signs the stored intent; clients cannot replace checkout fields', async () => {
91+
const issued = await intent()
92+
expect(
93+
(await post(`/onramper/intents/${issued.id}/checkout`, { token: issued.token, address: 'attacker' })).status,
94+
).toBe(400)
95+
expect((await post(`/onramper/intents/${issued.id}/checkout`, { token: '00'.repeat(32) })).status).toBe(401)
96+
expect(
97+
(await post(`/onramper/intents/${issued.id}/checkout`, { token: issued.token }, 'https://evil.example')).status,
98+
).toBe(401)
99+
})
100+
101+
test('an intent can be redeemed only once under concurrent requests', async () => {
102+
const issued = await intent()
103+
const responses = await Promise.all([
104+
post(`/onramper/intents/${issued.id}/checkout`, { token: issued.token }),
105+
post(`/onramper/intents/${issued.id}/checkout`, { token: issued.token }),
106+
])
107+
expect(responses.map((response) => response.status).sort()).toEqual([201, 401])
108+
})
109+
110+
test('expired intents and unsupported provider currencies are rejected', async () => {
111+
const issued = await intent()
112+
intents.get(issued.id).expiresAt = new Date(0)
113+
expect((await post(`/onramper/intents/${issued.id}/checkout`, { token: issued.token })).status).toBe(401)
114+
expect((await post('/onramper/intents', { ...checkout, crypto: 'unsupported' })).status).toBe(400)
115+
})
116+
117+
test('quota denial happens before provider work', async () => {
118+
consume.mockRejectedValueOnce(new HttpException('Too many requests', 429))
119+
expect((await post('/onramper/intents', checkout)).status).toBe(429)
120+
expect(catalogue.get).not.toHaveBeenCalled()
121+
})
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
2+
import {
3+
BadRequestException,
4+
Body,
5+
Controller,
6+
Header,
7+
Injectable,
8+
Param,
9+
Post,
10+
Req,
11+
ServiceUnavailableException,
12+
SetMetadata,
13+
UnauthorizedException,
14+
} from '@nestjs/common'
15+
import axios from 'axios'
16+
import type { Request } from 'express'
17+
import { z } from 'zod'
18+
19+
import { clientIp, SecurityRateLimiter } from '@shapeshift/shared-utils'
20+
21+
import { PrismaService } from '../prisma/prisma.service'
22+
23+
import { checkoutSchema, signWidgetUrl } from './sign-widget'
24+
25+
const supportedSchema = z.object({
26+
message: z.object({
27+
crypto: z.array(z.object({ id: z.string(), network: z.string().optional() })).max(20000),
28+
fiat: z.array(z.object({ code: z.string() })).max(500),
29+
}),
30+
})
31+
type Catalogue = z.infer<typeof supportedSchema>
32+
33+
@Injectable()
34+
export class OnramperCatalogue {
35+
private cache?: { data: Catalogue; expiresAt: number }
36+
private pending?: Promise<Catalogue>
37+
38+
async get(): Promise<Catalogue> {
39+
if (this.cache && this.cache.expiresAt > Date.now()) return this.cache.data
40+
if (this.pending !== undefined) return this.pending
41+
this.pending = this.fetch().finally(() => {
42+
this.pending = undefined
43+
})
44+
return this.pending
45+
}
46+
47+
private async fetch(): Promise<Catalogue> {
48+
const baseUrl = process.env.ONRAMPER_API_URL ?? 'https://api.onramper.com/'
49+
const base = new URL(baseUrl)
50+
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash)
51+
throw new ServiceUnavailableException('Invalid provider configuration')
52+
const response = await axios.get<unknown>(
53+
new URL('supported', base.href.endsWith('/') ? base.href : `${base.href}/`).href,
54+
{
55+
headers: { Authorization: process.env.ONRAMPER_API_KEY },
56+
timeout: 10000,
57+
maxRedirects: 0,
58+
maxContentLength: 2 * 1024 * 1024,
59+
},
60+
)
61+
const data = supportedSchema.parse(response.data)
62+
this.cache = { data, expiresAt: Date.now() + 60_000 }
63+
return data
64+
}
65+
}
66+
67+
@Controller('onramper')
68+
@SetMetadata('public-access', true)
69+
export class OnramperController {
70+
constructor(
71+
private readonly limits: SecurityRateLimiter,
72+
private readonly catalogue: OnramperCatalogue,
73+
private readonly db: PrismaService,
74+
) {}
75+
76+
@Header('Cache-Control', 'no-store')
77+
@Post('intents')
78+
async createIntent(@Req() request: Request, @Body() input: unknown) {
79+
const parsed = checkoutSchema.safeParse(input)
80+
if (!parsed.success) throw new BadRequestException('Invalid checkout request')
81+
await this.limits.consume('onramper-global', 'all', 1000)
82+
const origin = request.headers.origin
83+
if (!origin || !checkoutOrigins().includes(origin)) throw new BadRequestException('Unsupported application origin')
84+
await this.limits.consume(
85+
'onramper-ip',
86+
clientIp(request.socket.remoteAddress, request.headers['x-forwarded-for'] as string | undefined),
87+
30,
88+
)
89+
const apiKey = process.env.ONRAMPER_API_KEY
90+
const privateKey = process.env.ONRAMPER_PRIVATE_KEY
91+
if (!apiKey || !privateKey) throw new ServiceUnavailableException('Onramper signing is not configured')
92+
const checkout = parsed.data
93+
const catalogue = await this.catalogue.get().catch(() => {
94+
throw new ServiceUnavailableException('Provider unavailable')
95+
})
96+
if (
97+
!catalogue.message.crypto.some((crypto) => crypto.id === checkout.crypto) ||
98+
!catalogue.message.fiat.some((fiat) => fiat.code.toUpperCase() === checkout.fiat)
99+
)
100+
throw new BadRequestException('Unsupported currency')
101+
if (checkout.redirectUrl) {
102+
const redirect = new URL(checkout.redirectUrl)
103+
const allowed = process.env.ONRAMPER_REDIRECT_ORIGINS?.split(',').map((value) => value.trim()) ?? []
104+
if (!allowed.includes(redirect.origin) || redirect.username || redirect.password)
105+
throw new BadRequestException('Redirect is not allowed')
106+
}
107+
const token = randomBytes(32).toString('hex')
108+
const id = randomUUID()
109+
const expiresAt = new Date(Date.now() + 5 * 60_000)
110+
await this.db.onramperIntent.deleteMany({ where: { expiresAt: { lt: new Date() } } })
111+
await this.db.onramperIntent.create({
112+
data: { id, tokenHash: createHash('sha256').update(token).digest('hex'), origin, checkout, expiresAt },
113+
})
114+
return { id, token, expiresAt: expiresAt.toISOString() }
115+
}
116+
117+
@Header('Cache-Control', 'no-store')
118+
@Post('intents/:id/checkout')
119+
async checkout(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
120+
const parsed = z.strictObject({ token: z.string().regex(/^[a-f0-9]{64}$/) }).safeParse(body)
121+
if (!parsed.success || !z.uuid().safeParse(id).success) throw new BadRequestException('Invalid checkout intent')
122+
await this.limits.consume('onramper-redeem-global', 'all', 1000)
123+
await this.limits.consume(
124+
'onramper-redeem-ip',
125+
clientIp(request.socket.remoteAddress, request.headers['x-forwarded-for'] as string | undefined),
126+
30,
127+
)
128+
const intent = await this.db.onramperIntent.findUnique({ where: { id } })
129+
if (
130+
!intent ||
131+
intent.expiresAt <= new Date() ||
132+
intent.tokenHash !== createHash('sha256').update(parsed.data.token).digest('hex') ||
133+
intent.origin !== request.headers.origin ||
134+
!checkoutOrigins().includes(intent.origin)
135+
)
136+
throw new UnauthorizedException('Invalid or expired checkout intent')
137+
const checkout = checkoutSchema.parse(intent.checkout)
138+
const apiKey = process.env.ONRAMPER_API_KEY
139+
const privateKey = process.env.ONRAMPER_PRIVATE_KEY
140+
if (!apiKey || !privateKey) throw new ServiceUnavailableException('Onramper signing is not configured')
141+
const url = signWidgetUrl(checkout, {
142+
apiKey,
143+
privateKey,
144+
widgetUrl: process.env.ONRAMPER_WIDGET_URL ?? 'https://buy.onramper.com',
145+
redirectOrigins: process.env.ONRAMPER_REDIRECT_ORIGINS?.split(',').map((origin) => origin.trim()) ?? [],
146+
})
147+
const consumed = await this.db.onramperIntent.deleteMany({ where: { id, expiresAt: { gt: new Date() } } })
148+
if (consumed.count !== 1) throw new UnauthorizedException('Checkout intent already used')
149+
return { url }
150+
}
151+
}
152+
153+
function checkoutOrigins(): string[] {
154+
const values = process.env.ONRAMPER_ALLOWED_ORIGINS?.split(',').map((value) => value.trim()) ?? []
155+
if (
156+
!values.length ||
157+
values.some((value) => {
158+
try {
159+
const url = new URL(value)
160+
return (
161+
url.origin !== value ||
162+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost'))
163+
)
164+
} catch {
165+
return true
166+
}
167+
})
168+
)
169+
throw new ServiceUnavailableException('Invalid checkout origin configuration')
170+
return values
171+
}

0 commit comments

Comments
 (0)