From dfc275f8ac6be27a9d1a820e9808c45352c0d77f Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Sun, 18 Oct 2026 00:30:44 +0100 Subject: [PATCH] fix: SSE headers, future timestamp rejection, config caching, HSTS - Set Cache-Control and Connection headers in streaming interceptor for SSE (#702) - Reject future-dated oracle readings with 60s clock skew tolerance (#703) - Cache network passphrase, horizon URL, and RPC URL at startup in StellarService (#704) - Enable HSTS via Helmet with 1-year max-age, includeSubDomains, preload (#705) --- .../interceptors/streaming.interceptor.ts | 3 ++ src/main.ts | 6 ++++ src/oracle/oracle.service.spec.ts | 31 +++++++++++++++++++ src/oracle/oracle.service.ts | 12 +++++++ src/stellar/stellar.service.ts | 21 +++++++++++-- 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/common/interceptors/streaming.interceptor.ts b/src/common/interceptors/streaming.interceptor.ts index e78ae45..a829bfa 100644 --- a/src/common/interceptors/streaming.interceptor.ts +++ b/src/common/interceptors/streaming.interceptor.ts @@ -209,6 +209,9 @@ export class StreamingInterceptor implements NestInterceptor { const items = envelope['data'] as unknown[]; response.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); + // #702 — set SSE-compatible headers for streaming responses + response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + response.setHeader('Connection', 'keep-alive'); passThrough = new PassThrough(); diff --git a/src/main.ts b/src/main.ts index 3da54d9..ddd5286 100644 --- a/src/main.ts +++ b/src/main.ts @@ -86,6 +86,12 @@ async function bootstrap() { // Security headers (X-Content-Type-Options, X-Frame-Options, HSTS, etc.) app.use(helmet({ referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, + // #705 — enable HSTS to prevent SSL stripping attacks + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + }, })); // Response compression — gzip/deflate for text-based responses (JSON, HTML, diff --git a/src/oracle/oracle.service.spec.ts b/src/oracle/oracle.service.spec.ts index c8d5379..31df14d 100644 --- a/src/oracle/oracle.service.spec.ts +++ b/src/oracle/oracle.service.spec.ts @@ -557,6 +557,37 @@ describe("OracleService.fetchRainfall", () => { expect(mockPrismaService.oracleReading.upsert).not.toHaveBeenCalled(); }); + // #703 — reject future-dated readings + it("should reject readings with future timestamps (#703)", async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future + const mockReading = { + dataType: "weather", + key: "rainfall:0,0:2026-06", + value: "100000000", + confidence: 95, + timestamp: futureTimestamp, + source: "open-meteo", + }; + await expect(service.persistReading(mockReading)).rejects.toThrow( + /future/i, + ); + expect(mockPrismaService.oracleReading.upsert).not.toHaveBeenCalled(); + }); + + it("should accept readings within clock skew tolerance (#703)", async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const mockReading = { + dataType: "weather", + key: "rainfall:0,0:2026-06", + value: "100000000", + confidence: 95, + timestamp: nowSeconds + 30, // 30s ahead — within 60s tolerance + source: "open-meteo", + }; + await service.persistReading(mockReading); + expect(mockPrismaService.oracleReading.upsert).toHaveBeenCalled(); + }); + it("#171 — returns NO_DATA when data array element is null", async () => { mockConfigService.get.mockImplementation((key) => key === "AVIATIONSTACK_API_KEY" ? "test-key" : undefined, diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index c9281ad..f8d36b1 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -229,6 +229,18 @@ export class OracleService { ); return; } + // #703 — reject future-dated readings to prevent corrupting claim triggers. + // Allow 60s of clock skew tolerance. + const FUTURE_SKEW_TOLERANCE_SECONDS = 60; + const nowSeconds = Math.floor(Date.now() / 1000); + if (reading.timestamp > nowSeconds + FUTURE_SKEW_TOLERANCE_SECONDS) { + this.logger.error( + `Rejecting future-dated reading for key=${reading.key}: timestamp=${reading.timestamp} now=${nowSeconds}`, + ); + throw new BadRequestException( + `Reading timestamp ${reading.timestamp} is in the future (max ${FUTURE_SKEW_TOLERANCE_SECONDS}s clock skew allowed)`, + ); + } await this.prisma.oracleReading.upsert({ where: { key_source_bucket: { diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index 3406ff9..2c5a187 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -58,16 +58,22 @@ export class StellarService { private readonly horizon: Horizon.Server; private readonly network: string; private readonly keeperKeypair: Keypair; + // #704 — cache network config at startup to avoid repeated env lookups + private readonly _networkPassphrase: string; + private readonly _horizonUrl: string; + private readonly _rpcUrl: string; constructor(private readonly config: ConfigService) { const rpcUrl = config.get("STELLAR_RPC_URL") ?? "https://soroban-testnet.stellar.org"; this.rpc = new StellarRpc.Server(rpcUrl); + this._rpcUrl = rpcUrl; this.network = config.get("STELLAR_NETWORK") === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; + this._networkPassphrase = this.network; // #185 — balances only exist on a Horizon account response, not on the // lightweight object `rpc.Server.getAccount()` returns (that's meant for // building transactions: sequence number only, no `.balances`). A @@ -78,6 +84,7 @@ export class StellarService { ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org"); this.horizon = new Horizon.Server(horizonUrl); + this._horizonUrl = horizonUrl; const secret = config.get("KEEPER_SECRET_KEY"); if (!secret) { throw new Error( @@ -611,9 +618,19 @@ export class StellarService { return this.keeperKeypair.publicKey(); } - /** Return the current network passphrase. */ + /** Return the cached network passphrase (#704). */ get networkPassphrase(): string { - return this.network; + return this._networkPassphrase; + } + + /** Return the cached Horizon URL (#704). */ + get horizonUrl(): string { + return this._horizonUrl; + } + + /** Return the cached RPC URL (#704). */ + get rpcUrl(): string { + return this._rpcUrl; } get rpcServer(): StellarRpc.Server {