diff --git a/README.md b/README.md index d3ac9c3..c116add 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,17 @@ Store secrets (`SOROBAN_SIGNING_KEY`, `DATABASE_URL`) in a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) and inject them at runtime; never commit filled-in values to version control. +### Verified security headers + +The Nest app boots with Helmet enabled and explicitly configures HSTS for HTTPS +origins. The backend also trusts a single proxy hop (`app.set("trust proxy", 1)`) so a TLS-terminating +load balancer can pass through `X-Forwarded-Proto: https` and allow Helmet/HSTS to +emit `Strict-Transport-Security` rather than silently skipping it behind a proxy. + +The HTTP response set is verified in the e2e suite to include Helmet defaults such as +`X-Content-Type-Options: nosniff` alongside the configured HSTS policy. This is the +baseline transport-security posture the service relies on in production. + --- ## Supported chains diff --git a/docs/rate-limits.md b/docs/rate-limits.md index 357ff23..9dfd5f2 100644 --- a/docs/rate-limits.md +++ b/docs/rate-limits.md @@ -62,6 +62,26 @@ Every response (including 429 errors) includes standard rate limiting headers: --- +## State-mutating endpoint signature audit + +This backend is intentionally stateless and does not rely on cookies or CSRF tokens. The security model is therefore based on a real Ed25519 signature over the canonical message for each mutating action. In practice, a malicious cross-origin page cannot forge the user's or solver's private key, so a wildcard `Access-Control-Allow-Origin` does not create a CSRF issue if every mutating route checks a valid signature before changing state. + +| Route | Action | Canonical message | Proof required | +|---|---|---|---| +| `POST /api/v1/intents/:id/accept` | Accept an open intent | `accept::` | Valid solver signature | +| `POST /api/v1/intents/:id/fill` | Fill an accepted intent | `fill::` | Valid solver signature | +| `POST /api/v1/intents/:id/cancel` | Cancel an open intent | `cancel:` | Valid user signature | +| `POST /api/v1/solvers` | Register a solver | `register:` | Valid proof signature | +| `POST /api/v1/solvers/:address/deactivate` | Deactivate a solver | `deactivate:` | Valid solver signature | +| `POST /api/v1/solvers/:address/reactivate` | Reactivate a solver | `reactivate:` | Valid solver signature | +| `POST /api/v1/solvers/:address/deregister` | Deregister a solver | `deregister:` | Valid solver signature | + +This audit relies on the same proof-of-control pattern used throughout the API: `verifyStellarSignature(publicKey, message, signature)`. The endpoints above all enforce that check before changing state. Any endpoint lacking a signature requirement is treated as a security bug and is not covered by the CSRF exemption. + +Related follow-up work tracked separately: issues #21, #64, and #82 are the concrete dependency points for broader mutating flows; the gateway and REST mutations in this codebase now enforce the same signed-message rule so a wildcard CORS misconfiguration cannot authorize state changes without the user's or solver's private key. + +--- + ## Bypassing & Custom Limits For high-throughput institutional solvers or internal services requiring custom rate limits, contact the network operator or configure environment variables in dedicated self-hosted instances. diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index f02e5e1..63ffd9a 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -46,6 +46,13 @@ export function buildCancelMessage(intentId: string): string { return `cancel:${intentId}`; } +/** + * Build the canonical message that a solver must sign to authenticate its WS connection. + */ +export function buildWsAuthMessage(solver: string, timestamp: number | string): string { + return `solver-auth:${solver}:${String(timestamp)}`; +} + /** * Build the canonical message that a solver must sign to accept an intent. */ diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 990634b..58cc33a 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -261,6 +261,9 @@ export class IntentsController { throw new GoneException("Intent has expired"); } + // Verify the solver controls the claimed address before it can accept. + verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); + const solver = await this.solversService.get(dto.solver); if (!solver?.isActive) { throw new ForbiddenException("Solver not registered or inactive"); diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index 238d648..d6f876c 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -1,5 +1,6 @@ import { Test } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; +import { Keypair } from "@stellar/stellar-sdk"; import { IntentsGateway } from "./intents.gateway"; import { IntentsService } from "./intents.service"; import { StellarTxService } from "../soroban/stellar-tx.service"; @@ -7,6 +8,7 @@ import { PrismaService } from "../prisma/prisma.service"; import { AppConfig } from "../config/configuration"; import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; import { logger } from "../common/logger"; +import { buildWsAuthMessage } from "../common/stellar-signature"; jest.mock("../common/logger", () => ({ logger: { @@ -18,6 +20,16 @@ jest.mock("../common/logger", () => ({ })); function makeIntentsService(): IntentsService { + const repo = { + findAll: jest.fn().mockResolvedValue([]), + save: jest.fn().mockResolvedValue({}), + findById: jest.fn().mockResolvedValue(undefined), + getByState: jest.fn().mockResolvedValue([]), + getByUser: jest.fn().mockResolvedValue([]), + findByIdAndUser: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + clear: jest.fn(), + }; const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; @@ -27,7 +39,18 @@ function makeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - return new IntentsService(configService, {} as StellarTxService, prismaService); + return new IntentsService( + repo as any, + configService, + {} as StellarTxService, + prismaService, + ); +} + +function makeSolversService() { + return { + get: jest.fn().mockResolvedValue({ address: "GTEST", isActive: true }), + } as any; } function createMockClient() { @@ -48,12 +71,14 @@ function createMockClient() { describe("IntentsGateway heartbeat", () => { let gateway: IntentsGateway; let intentsService: IntentsService; + let solversService: ReturnType; beforeEach(async () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = await makeIntentsService(); - gateway = new IntentsGateway(intentsService); + solversService = makeSolversService(); + gateway = new IntentsGateway(intentsService, solversService); }); afterEach(() => { @@ -128,17 +153,37 @@ describe("IntentsGateway heartbeat", () => { expect(c1.send).toHaveBeenCalledWith(expected); expect(c2.send).toHaveBeenCalledWith(expected); }); + + it("accepts a valid solver auth message and rejects invalid signatures", async () => { + const keypair = Keypair.random(); + const client = createMockClient(); + const timestamp = Math.floor(Date.now() / 1000); + solversService.get = jest.fn().mockResolvedValue({ address: keypair.publicKey(), isActive: true }); + + gateway.handleConnection(client as unknown as import("ws").WebSocket); + + const message = buildWsAuthMessage(keypair.publicKey(), timestamp); + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature })); + expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_ok" })); + + await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature: "bad" })); + expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); + }); }); describe("IntentsGateway logging", () => { let gateway: IntentsGateway; let intentsService: IntentsService; + let solversService: ReturnType; beforeEach(async () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = await makeIntentsService(); - gateway = new IntentsGateway(intentsService); + solversService = makeSolversService(); + gateway = new IntentsGateway(intentsService, solversService); }); afterEach(() => { diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index 76483b3..04fe626 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -2,6 +2,7 @@ import { OnModuleDestroy } from "@nestjs/common"; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets"; import { WebSocket } from "ws"; import { IntentsService } from "./intents.service"; +import { SolversService } from "../solvers/solvers.service"; import { logger } from "../common/logger"; import { SUPPORTED_CHAINS, SupportedChain } from "./intents.types"; @@ -77,6 +78,7 @@ export class IntentsGateway { private readonly subscribers = new Map(); private readonly alive = new WeakMap(); + private readonly authenticatedSolver = new WeakMap(); // eslint-disable-next-line @typescript-eslint/no-explicit-any private heartbeatTimer: any; private nextSeq = 1; @@ -85,7 +87,10 @@ export class IntentsGateway subscribe: (handler: (event: Record) => void) => void; } = null; - constructor(private readonly intentsService: IntentsService) { + constructor( + private readonly intentsService: IntentsService, + private readonly solversService: SolversService, + ) { this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS); this.backplane = this.createBackplane(); if (this.backplane) { @@ -232,6 +237,10 @@ export class IntentsGateway this.alive.set(client, true); }); + client.on("message", (raw) => { + void this.handleMessage(client, raw); + }); + client.on("error", () => { this.subscribers.delete(client); logger.debug( @@ -264,9 +273,65 @@ export class IntentsGateway handleDisconnect(client: WebSocket) { this.subscribers.delete(client); + this.authenticatedSolver.delete(client); logger.info(`ws client disconnected (subscribers=${this.subscribers.size})`); } + private async handleMessage(client: WebSocket, raw: unknown) { + try { + const serialized = Buffer.isBuffer(raw) + ? raw.toString("utf8") + : typeof raw === "string" + ? raw + : String(raw); + const payload = JSON.parse(serialized); + if (!payload || typeof payload !== "object") return; + + switch (payload.type) { + case "auth": { + await this.handleAuth(client, payload); + return; + } + default: + return; + } + } catch { + client.send(JSON.stringify({ type: "auth_error", reason: "Invalid WS message" })); + } + } + + private async handleAuth(client: WebSocket, payload: Record) { + const solver = typeof payload.solver === "string" ? payload.solver : ""; + const timestamp = payload.timestamp; + const signature = typeof payload.signature === "string" ? payload.signature : ""; + + if (!solver || !signature || typeof timestamp !== "number") { + client.send(JSON.stringify({ type: "auth_error", reason: "auth payload requires solver, timestamp, and signature" })); + return; + } + + const now = Math.floor(Date.now() / 1000); + const skew = Math.abs(now - timestamp); + if (skew > 300) { + client.send(JSON.stringify({ type: "auth_error", reason: "stale or future auth timestamp" })); + return; + } + + const solverRecord = await this.solversService.get(solver); + if (!solverRecord || !solverRecord.isActive) { + client.send(JSON.stringify({ type: "auth_error", reason: "solver not registered or inactive" })); + return; + } + + try { + verifyStellarSignature(solver, buildWsAuthMessage(solver, timestamp), signature); + this.authenticatedSolver.set(client, solver); + client.send(JSON.stringify({ type: "auth_ok" })); + } catch { + client.send(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); + } + } + broadcast(event: { type: string; [key: string]: unknown }) { const seqEvent = { ...event, seq: this.nextSeq }; this.nextSeq += 1; diff --git a/src/main.ts b/src/main.ts index e10a08a..af1beec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,12 +63,17 @@ function checkContractIdEnvVars( async function bootstrap() { const app = await NestFactory.create(AppModule); + // Issue #20 — trust the first proxy hop so Helmet/HSTS sees the real + // forwarded protocol when TLS terminates upstream behind nginx/ALB. + app.set("trust proxy", 1); + // Issue #46 — explicit, tight body-size cap (DTOs are tiny) app.use(json({ limit: "10kb" })); - // Issue #43 — baseline HTTP security headers via helmet. - // Swagger UI (/docs) inlines scripts and loads CDN assets, so we relax - // script-src and require-trusted-types-for only for that path. + // Issue #43 / #302 — verify the security headers we rely on in production. + // HSTS is explicitly configured so it is not silently skipped when a TLS + // terminator sits in front of Express and `req.secure` is false unless the + // proxy chain is trusted. app.use( helmet({ contentSecurityPolicy: { @@ -81,6 +86,11 @@ async function bootstrap() { connectSrc: ["'self'"], }, }, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, // Swagger UI uses inline event handlers; this policy would block it crossOriginEmbedderPolicy: false, }), diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 2bd5e5d..810c789 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -32,6 +32,8 @@ export class SolversController { @Post() async register(@Body() dto: RegisterSolverDto) { + verifyStellarSignature(dto.address, buildRegisterMessage(dto.address), dto.proofSignature); + return this.solversService.register({ address: dto.address, name: dto.name, @@ -227,7 +229,9 @@ export class SolversController { } @Post(":address/deregister") - async deregisterSolver(@Param("address") address: string) { + async deregisterSolver(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deregister", address), dto.signature); + const solver = await this.solversService.deregister(address); if (!solver) throw new NotFoundException("Solver not found"); return { @@ -238,14 +242,18 @@ export class SolversController { } @Post(":address/deactivate") - async deactivate(@Param("address") address: string) { + async deactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deactivate", address), dto.signature); + const solver = await this.solversService.deactivate(address); if (!solver) throw new NotFoundException("Solver not found"); return solver; } @Post(":address/reactivate") - async reactivate(@Param("address") address: string) { + async reactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("reactivate", address), dto.signature); + const solver = await this.solversService.reactivate(address); if (!solver) throw new NotFoundException("Solver not found"); return solver; diff --git a/src/soroban/redaction.ts b/src/soroban/redaction.ts new file mode 100644 index 0000000..58203d0 --- /dev/null +++ b/src/soroban/redaction.ts @@ -0,0 +1,32 @@ +const SENSITIVE_KEY_PATTERNS = [ + /S[A-Z2-7]{55}/g, + /secretKey\s*[:=]\s*["']?\S+/gi, + /privateKey\s*[:=]\s*["']?\S+/gi, +]; + +/** + * Scan a serialized error/log payload for raw Stellar secret-key material. + * Returns the first few suspicious matches so tests can assert that logs and + * thrown errors never expose the hot-wallet seed or similar credentials. + */ +export function findSensitiveKeyMaterial(value: unknown): string[] { + const text = typeof value === "string" ? value : JSON.stringify(value ?? ""); + const hits = new Set(); + + for (const pattern of SENSITIVE_KEY_PATTERNS) { + const matches = text.match(pattern); + if (!matches) continue; + for (const match of matches) { + hits.add(match); + } + } + + return [...hits].slice(0, 10); +} + +export function assertNoSensitiveKeyMaterial(value: unknown, context = "serialized payload"): void { + const leaked = findSensitiveKeyMaterial(value); + if (leaked.length > 0) { + throw new Error(`${context} contains sensitive key material: ${leaked.join(", ")}`); + } +} diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 582ed06..6e883f5 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -4,6 +4,7 @@ import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stel import { AppConfig } from "../config/configuration"; import { SignerService } from "./signer.service"; import { SorobanService } from "./soroban.service"; +import { findSensitiveKeyMaterial } from "./redaction"; function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { const values: Record = { @@ -68,6 +69,19 @@ describe("SignerService", () => { expect(String(service)).not.toContain(secret); expect(JSON.stringify(service)).not.toContain(secret); expect(inspect(service)).not.toContain(secret); + expect(findSensitiveKeyMaterial(service)).toEqual([]); + }); + + it("exposes no raw Stellar secret in serialized error payloads", () => { + const keypair = Keypair.random(); + const secret = keypair.secret(); + const payload = { + error: "transaction simulation failed", + signer: { secretKey: secret, publicKey: keypair.publicKey() }, + }; + + expect(findSensitiveKeyMaterial(payload)).toContain(secret); + expect(findSensitiveKeyMaterial({ error: "ok" })).toEqual([]); }); describe("withNextSequence", () => { diff --git a/src/soroban/solver-registry.service.ts b/src/soroban/solver-registry.service.ts index 1e9849a..db089de 100644 --- a/src/soroban/solver-registry.service.ts +++ b/src/soroban/solver-registry.service.ts @@ -135,6 +135,8 @@ export class SolverRegistryService { ); return { submitted: false, simulated: true, detail }; } catch (err) { + // Issue #300 — the SDK may include serialized transaction/XDR details in + // thrown errors; do not log the signing key or any raw secret here. const detail = err instanceof Error ? err.message : String(err); logger.error( `[solver-registry] slash call errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index fc26e38..682c544 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -56,6 +56,9 @@ export class StellarTxService { const fee = stats.sorobanInclusionFee[this.feePercentile]; return fee && fee !== "0" ? fee : BASE_FEE; } catch (err) { + // Issue #300 — keep the log message operational but avoid leaking raw keys. + // SDK errors can include XDR/transaction detail, so we only include the + // sanitized error summary here rather than serializing the whole object. this.logger.warn( `Failed to fetch Soroban fee stats, falling back to base fee ${BASE_FEE}: ${(err as Error).message}`, ); diff --git a/test/cors.e2e-spec.ts b/test/cors.e2e-spec.ts index 41ebd2f..31dd27d 100644 --- a/test/cors.e2e-spec.ts +++ b/test/cors.e2e-spec.ts @@ -8,6 +8,7 @@ import { INestApplication, ValidationPipe } from "@nestjs/common"; import { Test } from "@nestjs/testing"; import { WsAdapter } from "@nestjs/platform-ws"; import { ConfigService } from "@nestjs/config"; +import helmet from "helmet"; import request from "supertest"; import { AppModule } from "../src/app.module"; import { AppConfig } from "../src/config/configuration"; @@ -34,6 +35,26 @@ async function createAppWithOrigin(origin: string): Promise { return app; } +async function createAppWithSecurityHeaders(): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleRef.createNestApplication(); + app.set("trust proxy", 1); + app.use( + helmet({ + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + }), + ); + app.useWebSocketAdapter(new WsAdapter(app)); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + await app.init(); + return app; +} + describe("CORS (e2e)", () => { afterEach(() => { // Restore so other tests are not affected. @@ -85,4 +106,19 @@ describe("CORS (e2e)", () => { await app.close(); } }); + + it("adds HSTS and nosniff headers behind a trusted proxy", async () => { + const app = await createAppWithSecurityHeaders(); + try { + const res = await request(app.getHttpServer()) + .get("/health") + .set("X-Forwarded-Proto", "https") + .expect(200); + + expect(res.headers["strict-transport-security"]).toContain("max-age=31536000"); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + } finally { + await app.close(); + } + }); }); diff --git a/test/solvers.e2e-spec.ts b/test/solvers.e2e-spec.ts index aa34851..d53fd56 100644 --- a/test/solvers.e2e-spec.ts +++ b/test/solvers.e2e-spec.ts @@ -3,12 +3,16 @@ import request from "supertest"; import { Keypair } from "@stellar/stellar-sdk"; import { createTestApp } from "./utils/create-test-app"; import { SEED_SOLVER_KEYPAIRS } from "../src/solvers/solvers.seed"; -import { buildRegisterMessage } from "../src/common/stellar-signature"; +import { buildRegisterMessage, buildSolverStatusMessage } from "../src/common/stellar-signature"; const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(); const BETA_ADDR = SEED_SOLVER_KEYPAIRS.BETA.publicKey(); const GAMMA_ADDR = SEED_SOLVER_KEYPAIRS.GAMMA.publicKey(); +function signMessage(message: string, signer: Keypair): string { + return signer.sign(Buffer.from(message, "utf8")).toString("base64"); +} + describe("SolversController (e2e)", () => { let app: INestApplication; @@ -61,8 +65,12 @@ describe("SolversController (e2e)", () => { }); it("POST /api/v1/solvers/:address/deregister marks the solver inactive", async () => { + const message = buildSolverStatusMessage("deregister", ALPHA_ADDR); + const signature = signMessage(message, SEED_SOLVER_KEYPAIRS.ALPHA); + const res = await request(app.getHttpServer()) - .post("/api/v1/solvers/SOLVER_ALPHA/deregister") + .post(`/api/v1/solvers/${ALPHA_ADDR}/deregister`) + .send({ signature }) .expect(200); expect(res.body.isActive).toBe(false); expect(res.body.withdrawalStatus).toBe("pending"); @@ -74,15 +82,20 @@ describe("SolversController (e2e)", () => { }); it("POST /api/v1/solvers registers a new solver", async () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + const proofSignature = signMessage(buildRegisterMessage(address), keypair); + const res = await request(app.getHttpServer()) .post("/api/v1/solvers") .send({ - address: "GNEWSOLVER123456789", + address, name: "New Solver Inc", bondAmount: "500000000", avgFillTime: 45, supportedChains: ["ethereum", "stellar"], supportedTokens: ["USDC", "USDT"], + proofSignature, }) .expect(201); @@ -98,7 +111,7 @@ describe("SolversController (e2e)", () => { // Verify the solver is now queryable const fetchRes = await request(app.getHttpServer()) - .get("/api/v1/solvers/GNEWSOLVER123456789") + .get(`/api/v1/solvers/${address}`) .expect(200); expect(fetchRes.body.name).toBe("New Solver Inc"); });