Skip to content

Commit b949b35

Browse files
devmocreaDeveloper
andauthored
Fix/security and configuration issues (#149)
* Remove ephemeral keypair fallback in stellar service The service previously generated a random keypair when KEEPER_SECRET_KEY was unset, creating an unfunded account that would fail all contract invocations. This made the entire Soroban interaction layer non-functional in that configuration. Changes: - Throw explicit error on startup if KEEPER_SECRET_KEY is missing - Remove Keypair.random() fallback path - Update .env.example with detailed instructions for generating and funding a testnet keypair * Secure AviationStack API communication The flight delay oracle was transmitting the API key over plain HTTP in the URL query string, exposing it to network intermediaries. Changes: - Switch from http:// to https:// for TLS encryption - Move API key from URL query parameter to Authorization header - Prevents key exposure in URL logs and network traces * Implement rate limiting with memory-safe throttle guard The API was previously unthrottled, making brute-force attacks and denial-of-service trivial. The ThrottleGuard was defined but never registered, and the in-memory Map leaked entries for IPs that stopped making requests. Changes: - Create ThrottleGuard with 60 requests per minute per IP limit - Add periodic cleanup timer to evict stale entries and prevent unbounded memory growth - Register guard globally in main.ts to protect all routes - Return HTTP 429 with retryAfter header when limit exceeded --------- Co-authored-by: Developer <developer@remitlend.local>
1 parent a79c9ce commit b949b35

5 files changed

Lines changed: 101 additions & 5 deletions

File tree

.env.example

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
1919
STELLAR_NETWORK="testnet"
2020

2121
# Secret key for the keeper account that submits oracle data and triggers claims — REQUIRED
22-
# Generate with: stellar keys generate keeper --network testnet
22+
# This account must be funded on the network before the service can submit transactions.
23+
#
24+
# To generate and fund a testnet keypair:
25+
# 1. Generate: stellar keys generate keeper --network testnet
26+
# 2. Fund the account: Visit https://laboratory.stellar.org/#account-creator
27+
# or use: curl "https://friendbot.stellar.org?addr=<PUBLIC_KEY>"
28+
# 3. Verify balance: stellar account <PUBLIC_KEY> --network testnet
29+
#
30+
# The keeper account signs all oracle submissions and claim payouts. Without a valid
31+
# funded keypair, all Soroban contract invocations will fail with "account not found".
2332
KEEPER_SECRET_KEY="S..."
2433

2534
# Contract IDs for deployed Soroban contracts (set after deploy_testnet.sh)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
HttpException,
6+
HttpStatus,
7+
} from '@nestjs/common';
8+
import { Request } from 'express';
9+
10+
interface RequestWindow {
11+
count: number;
12+
windowStart: number;
13+
}
14+
15+
@Injectable()
16+
export class ThrottleGuard implements CanActivate {
17+
private readonly requests = new Map<string, RequestWindow>();
18+
private readonly MAX_REQUESTS = 60;
19+
private readonly TIME_WINDOW_MS = 60_000;
20+
21+
constructor() {
22+
setInterval(() => {
23+
const now = Date.now();
24+
for (const [ip, window] of this.requests) {
25+
if (now - window.windowStart > this.TIME_WINDOW_MS) {
26+
this.requests.delete(ip);
27+
}
28+
}
29+
}, this.TIME_WINDOW_MS).unref();
30+
}
31+
32+
canActivate(context: ExecutionContext): boolean {
33+
const request = context.switchToHttp().getRequest<Request>();
34+
const ip = this.extractIP(request);
35+
const now = Date.now();
36+
37+
const window = this.requests.get(ip);
38+
39+
if (!window || now - window.windowStart > this.TIME_WINDOW_MS) {
40+
this.requests.set(ip, { count: 1, windowStart: now });
41+
return true;
42+
}
43+
44+
if (window.count >= this.MAX_REQUESTS) {
45+
const retryAfter = Math.ceil(
46+
(window.windowStart + this.TIME_WINDOW_MS - now) / 1000,
47+
);
48+
throw new HttpException(
49+
{
50+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
51+
message: 'Too many requests. Please try again later.',
52+
retryAfter,
53+
},
54+
HttpStatus.TOO_MANY_REQUESTS,
55+
{
56+
cause: { retryAfter },
57+
},
58+
);
59+
}
60+
61+
window.count++;
62+
return true;
63+
}
64+
65+
private extractIP(request: Request): string {
66+
const forwarded = request.headers['x-forwarded-for'];
67+
if (typeof forwarded === 'string') {
68+
return forwarded.split(',')[0].trim();
69+
}
70+
return request.ip ?? request.socket.remoteAddress ?? 'unknown';
71+
}
72+
}

src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
44
import { AppModule } from './app.module';
55
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
66
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
7+
import { ThrottleGuard } from './common/guards/throttle.guard';
78
import helmet from 'helmet';
89
import { ConfigService } from '@nestjs/config';
910

@@ -31,6 +32,9 @@ async function bootstrap() {
3132
// Global interceptors
3233
app.useGlobalInterceptors(new LoggingInterceptor());
3334

35+
// Global guards
36+
app.useGlobalGuards(new ThrottleGuard());
37+
3438
// Global validation pipe
3539
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
3640

src/oracle/oracle.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,15 @@ export class OracleService {
256256
"AviationStack API is not configured.",
257257
);
258258
}
259-
const url = `http://api.aviationstack.com/v1/flights?access_key=${apiKey}&flight_iata=${flightNumber}&flight_date=${date}`;
259+
const url = `https://api.aviationstack.com/v1/flights?flight_iata=${flightNumber}&flight_date=${date}`;
260260
const res = await axios.get<{
261261
data: Array<{ departure: { delay: number } }>;
262-
}>(url, { timeout: 10_000 });
262+
}>(url, {
263+
timeout: 10_000,
264+
headers: {
265+
'Authorization': `Bearer ${apiKey}`
266+
}
267+
});
263268
const delay = res.data.data?.[0]?.departure?.delay ?? 0;
264269

265270
const oracleReading: OracleReading = {

src/stellar/stellar.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ export class StellarService {
3737
config.get<string>("STELLAR_NETWORK") === "mainnet"
3838
? Networks.PUBLIC
3939
: Networks.TESTNET;
40-
const secret = config.get<string>("KEEPER_SECRET_KEY") ?? "";
41-
this.keeperKeypair = secret ? Keypair.fromSecret(secret) : Keypair.random();
40+
const secret = config.get<string>("KEEPER_SECRET_KEY");
41+
if (!secret) {
42+
throw new Error(
43+
"KEEPER_SECRET_KEY environment variable is required. " +
44+
"Generate a testnet keypair with: stellar keys generate keeper --network testnet"
45+
);
46+
}
47+
this.keeperKeypair = Keypair.fromSecret(secret);
4248
}
4349

4450
/** Simulate a read-only contract invocation and return the result XDR. */

0 commit comments

Comments
 (0)