Skip to content
Open
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: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,15 @@ SETTLEMENT_CONTRACT_ID=
SOLVER_REGISTRY_CONTRACT_ID=

# ─── CORS ────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins for the frontend ("*" for any in dev)
# Comma-separated list of allowed origins for the frontend.
# Development default: "*" (any origin allowed — convenient for local work)
# Production REQUIRED: must be an explicit origin, never "*"
# e.g. CORS_ORIGIN=https://app.vortex.trade
# The app will refuse to start in production if this is "*" or unset.
CORS_ORIGIN=*

# ─── WebSocket ───────────────────────────────────────────────────────────────
# Maximum number of concurrent WebSocket connections accepted by the gateway.
# Connections beyond this limit are rejected with close code 1013 (try again later).
# Set to 0 to disable the cap (not recommended in production).
WS_MAX_CONNECTIONS=1000
18 changes: 15 additions & 3 deletions src/common/logging.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ interface LoggableResponse {
statusCode: number;
}

/**
* Strips newline characters and other ASCII control characters (except tab)
* from a string to prevent log injection attacks. A crafted URL containing
* `%0a` / `%0d` sequences could otherwise forge extra log lines.
*/
function sanitizeForLog(value: string): string {
// Replace newlines, carriage returns, and all other C0/C1 control characters
// (except horizontal tab U+0009) with a space so the entry stays on one line.
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, " ").trim();
}

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
Expand All @@ -21,9 +33,9 @@ export class LoggingInterceptor implements NestInterceptor {
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
logger.info(
`${request.method} ${request.originalUrl} ${response.statusCode} ${duration}ms`,
);
// Sanitize the URL before logging to prevent log-injection via crafted paths.
const safeUrl = sanitizeForLog(request.originalUrl);
logger.info(`${request.method} ${safeUrl} ${response.statusCode} ${duration}ms`);
}),
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface AppConfig {
solverRegistryContractId: string;
};
corsOrigin: string;
/** Maximum concurrent WebSocket connections (0 = unlimited). */
wsMaxConnections: number;
}

export default (): AppConfig => ({
Expand All @@ -20,4 +22,5 @@ export default (): AppConfig => ({
solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "",
},
corsOrigin: process.env.CORS_ORIGIN ?? "*",
wsMaxConnections: parseInt(process.env.WS_MAX_CONNECTIONS ?? "1000", 10),
});
31 changes: 30 additions & 1 deletion src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,34 @@ export const envValidationSchema = Joi.object({
SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""),
SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""),

CORS_ORIGIN: Joi.string().default("*"),
/**
* Allowed CORS origin(s) — comma-separated list of explicit origins or "*".
*
* In production the wildcard "*" is rejected so that deployments cannot
* accidentally serve credentialed-adjacent requests from any origin.
* Set this to the frontend's production URL, e.g.:
* CORS_ORIGIN=https://app.vortex.trade
*
* In development/test the value defaults to "*" for convenience.
*/
CORS_ORIGIN: Joi.when("NODE_ENV", {
is: "production",
then: Joi.string()
.invalid("*")
.required()
.messages({
"any.invalid":
'CORS_ORIGIN must be set to an explicit origin (not "*") when NODE_ENV=production',
"any.required":
'CORS_ORIGIN is required when NODE_ENV=production — set it to the frontend origin (e.g. https://app.vortex.trade)',
}),
otherwise: Joi.string().default("*"),
}),

/**
* Maximum number of simultaneous WebSocket connections the gateway will
* accept before rejecting new ones with close code 1013 (try again later).
* Defaults to 1000. Set to 0 to disable the cap (not recommended in prod).
*/
WS_MAX_CONNECTIONS: Joi.number().integer().min(0).default(1000),
});
56 changes: 54 additions & 2 deletions src/intents/intents.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,67 @@
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { WebSocket } from "ws";
import { IntentsService } from "./intents.service";
import { AppConfig } from "../config/configuration";

/**
* Authentication / access-control decision (issue #49)
* ───────────────────────────────────────────────────────
* The intent feed is intentionally PUBLIC and READ-ONLY. Any client may
* connect and receive real-time intent events without presenting credentials.
* This mirrors the design of public DEX order-book streams (e.g. dYdX, Stellar
* Horizon) where transparency is a protocol property.
*
* Solver bots submit intents and accept/fill them through the authenticated
* REST API (POST /api/v1/intents, POST /api/v1/intents/:id/accept, etc.).
* The WS gateway never accepts writes, so there is no privileged action to
* protect here.
*
* If a private/authenticated stream is needed in the future (e.g. per-solver
* private fills), add a separate gateway path (e.g. /ws/solver) and apply
* a NestJS WsGuard there.
*/
@WebSocketGateway({ path: "/ws" })
export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(IntentsGateway.name);
private readonly subscribers = new Set<WebSocket>();
/** Configured via WS_MAX_CONNECTIONS env var (default 1000, 0 = unlimited). */
private readonly maxConnections: number;

constructor(private readonly intentsService: IntentsService) {}
constructor(
private readonly intentsService: IntentsService,
private readonly configService: ConfigService<AppConfig, true>,
) {
this.maxConnections = this.configService.get("wsMaxConnections", { infer: true });
}

/**
* Returns the current number of active WebSocket subscribers.
* Exposed for metrics / health checks (issue #50 / #65).
*/
get subscriberCount(): number {
return this.subscribers.size;
}

handleConnection(client: WebSocket) {
// ── Max-connections guard (issue #50) ────────────────────────────────────
// Reject the connection before adding it to the subscriber set so the cap
// is never exceeded. Close code 1013 = "Try Again Later" (RFC 6455).
if (this.maxConnections > 0 && this.subscribers.size >= this.maxConnections) {
this.logger.warn(
`WS connection rejected: subscriber limit reached (${this.maxConnections})`,
);
client.close(1013, "Server at capacity — try again later");
return;
}

this.subscribers.add(client);
client.on("error", () => this.subscribers.delete(client));
this.logger.debug(`WS client connected — active subscribers: ${this.subscribers.size}`);
client.on("error", () => {
this.subscribers.delete(client);
this.logger.debug(`WS client error/drop — active subscribers: ${this.subscribers.size}`);
});

client.send(JSON.stringify({ type: "connected", message: "Vortex intent stream" }));

Expand All @@ -20,6 +71,7 @@ export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect

handleDisconnect(client: WebSocket) {
this.subscribers.delete(client);
this.logger.debug(`WS client disconnected — active subscribers: ${this.subscribers.size}`);
}

broadcast(event: { type: string; [key: string]: unknown }) {
Expand Down