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
3 changes: 3 additions & 0 deletions src/common/interceptors/streaming.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/oracle/oracle.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/oracle/oracle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
21 changes: 19 additions & 2 deletions src/stellar/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("STELLAR_RPC_URL") ??
"https://soroban-testnet.stellar.org";
this.rpc = new StellarRpc.Server(rpcUrl);
this._rpcUrl = rpcUrl;
this.network =
config.get<string>("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
Expand All @@ -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<string>("KEEPER_SECRET_KEY");
if (!secret) {
throw new Error(
Expand Down Expand Up @@ -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 {
Expand Down
Loading