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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/rate-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<intentId>:<solverAddress>` | Valid solver signature |
| `POST /api/v1/intents/:id/fill` | Fill an accepted intent | `fill:<intentId>:<solverAddress>` | Valid solver signature |
| `POST /api/v1/intents/:id/cancel` | Cancel an open intent | `cancel:<intentId>` | Valid user signature |
| `POST /api/v1/solvers` | Register a solver | `register:<solverAddress>` | Valid proof signature |
| `POST /api/v1/solvers/:address/deactivate` | Deactivate a solver | `deactivate:<solverAddress>` | Valid solver signature |
| `POST /api/v1/solvers/:address/reactivate` | Reactivate a solver | `reactivate:<solverAddress>` | Valid solver signature |
| `POST /api/v1/solvers/:address/deregister` | Deregister a solver | `deregister:<solverAddress>` | 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.
7 changes: 7 additions & 0 deletions src/common/stellar-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
3 changes: 3 additions & 0 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
51 changes: 48 additions & 3 deletions src/intents/intents.gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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";
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: {
Expand All @@ -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<AppConfig, true>;
Expand All @@ -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() {
Expand All @@ -48,12 +71,14 @@ function createMockClient() {
describe("IntentsGateway heartbeat", () => {
let gateway: IntentsGateway;
let intentsService: IntentsService;
let solversService: ReturnType<typeof makeSolversService>;

beforeEach(async () => {
jest.useFakeTimers();
jest.clearAllMocks();
intentsService = await makeIntentsService();
gateway = new IntentsGateway(intentsService);
solversService = makeSolversService();
gateway = new IntentsGateway(intentsService, solversService);
});

afterEach(() => {
Expand Down Expand Up @@ -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<typeof makeSolversService>;

beforeEach(async () => {
jest.useFakeTimers();
jest.clearAllMocks();
intentsService = await makeIntentsService();
gateway = new IntentsGateway(intentsService);
solversService = makeSolversService();
gateway = new IntentsGateway(intentsService, solversService);
});

afterEach(() => {
Expand Down
67 changes: 66 additions & 1 deletion src/intents/intents.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -77,6 +78,7 @@ export class IntentsGateway
{
private readonly subscribers = new Map<WebSocket, SubscriberFilter>();
private readonly alive = new WeakMap<WebSocket, boolean>();
private readonly authenticatedSolver = new WeakMap<WebSocket, string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private heartbeatTimer: any;
private nextSeq = 1;
Expand All @@ -85,7 +87,10 @@ export class IntentsGateway
subscribe: (handler: (event: Record<string, unknown>) => 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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, unknown>) {
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;
Expand Down
16 changes: 13 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
}),
Expand Down
14 changes: 11 additions & 3 deletions src/solvers/solvers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/soroban/redaction.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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(", ")}`);
}
}
Loading