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
87 changes: 87 additions & 0 deletions .github/workflows/contract-fuzzing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Contract Fuzzing

on:
workflow_dispatch:
schedule:
- cron: "17 2 * * *"

concurrency:
group: contract-fuzzing-${{ github.ref }}
cancel-in-progress: true

jobs:
lending-fuzz:
name: Lending contract fuzzing
runs-on: ubuntu-latest
timeout-minutes: 35
env:
CARGO_TERM_COLOR: always
FUZZ_TARGET: lending_actions
FUZZ_SECONDS: "1800"
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install clang and LLVM
run: |
sudo apt-get update
sudo apt-get install -y clang llvm lcov

- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly

- name: Cache cargo registry and fuzz build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
stellar-lend/target
stellar-lend/fuzz/target
key: ${{ runner.os }}-cargo-fuzz-long-${{ hashFiles('stellar-lend/Cargo.lock', 'stellar-lend/fuzz/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-fuzz-long-

- name: Install cargo-fuzz
run: cargo +nightly install cargo-fuzz --locked

- name: Validate seed corpus
run: bash scripts/fuzz/check_corpus.sh

- name: Run lending fuzz target for 30 minutes
working-directory: stellar-lend
run: |
cargo +nightly fuzz run "$FUZZ_TARGET" \
fuzz/corpus/"$FUZZ_TARGET" \
-- \
-max_total_time="$FUZZ_SECONDS" \
-timeout=10 \
-max_len=2048 \
-artifact_prefix=fuzz/artifacts/"$FUZZ_TARGET"/ \
2>&1 | tee fuzz-"$FUZZ_TARGET".log

- name: Generate coverage report
if: always()
working-directory: stellar-lend
run: |
cargo +nightly fuzz coverage "$FUZZ_TARGET" \
fuzz/corpus/"$FUZZ_TARGET" \
2>&1 | tee fuzz-coverage-"$FUZZ_TARGET".log || true

- name: Triage first crash
if: failure()
run: bash scripts/fuzz/triage_crash.sh "$FUZZ_TARGET"

- name: Upload fuzz logs and coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-report-${{ github.sha }}
path: |
stellar-lend/fuzz-*.log
stellar-lend/fuzz/coverage/
stellar-lend/fuzz/artifacts/
stellar-lend/fuzz/regressions/
retention-days: 30
21 changes: 20 additions & 1 deletion api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import zkProofRoutes from './routes/zkProof.routes';
import verificationRoutes from './routes/verification.routes';
import configRoutes from './routes/config.routes';
import analyticsRoutes from './routes/analytics.routes';
import developerRoutes from './routes/developer.routes';
import { errorHandler } from './middleware/errorHandler';
import { idempotencyMiddleware } from './middleware/idempotency';
import { resetSensitiveRateLimits, sensitiveOperationRateLimiter } from './middleware/rate-limit';
import { swaggerSpec } from './config/swagger';
import logger from './utils/logger';
import { requestIdMiddleware } from './middleware/requestId';
Expand Down Expand Up @@ -64,6 +66,15 @@ const corsOptions: cors.CorsOptions = {
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Authorization',
'Content-Type',
'Idempotency-Key',
'X-API-Key',
'X-Developer-Id',
'X-User-Address',
],
};
app.use(cors(corsOptions));
app.use(express.json({ limit: config.bodySizeLimit.limit }));
Expand Down Expand Up @@ -112,9 +123,16 @@ app.get('/api/openapi.json', (_req, res) => {
res.json(swaggerSpec);
});

app.use('/api/developer', developerRoutes);
app.use('/api/health', healthRoutes);
app.use('/api/protocol', protocolRoutes);
app.use('/api/lending', idempotencyMiddleware, userRateLimiter, lendingRoutes);
app.use(
'/api/lending',
idempotencyMiddleware,
userRateLimiter,
sensitiveOperationRateLimiter,
lendingRoutes
);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/portfolio', portfolioRoutes);
app.use('/api/gas', userRateLimiter, gasRoutes);
Expand All @@ -131,6 +149,7 @@ app.use(errorHandler);
void redisCacheService.warmup();

export async function resetRateLimiters(): Promise<void> {
resetSensitiveRateLimits();
await Promise.all([ipRateLimitStore.resetAll(), userRateLimitStore.resetAll()]);
}

Expand Down
83 changes: 83 additions & 0 deletions api/src/middleware/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Request, Response } from 'express';
import {
getSensitiveRateLimitAnalytics,
resetSensitiveRateLimits,
sensitiveOperationRateLimiter,
} from '../rate-limit';

function makeResponse(): Response {
const res = {
setHeader: jest.fn(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
return res as unknown as Response;
}

function runLimiter(req: Partial<Request>, res: Response = makeResponse()): Response {
const next = jest.fn();
sensitiveOperationRateLimiter(req as Request, res, next);
return res;
}

describe('sensitiveOperationRateLimiter', () => {
beforeEach(() => {
delete process.env.RATE_LIMIT_TRUSTED_USERS;
resetSensitiveRateLimits();
});

it('sets standard rate limit headers for sensitive operations', () => {
const res = runLimiter({
path: '/prepare/borrow',
params: {},
query: { userAddress: 'GBORROWER' },
body: {},
headers: {},
ip: '127.0.0.1',
});

expect(res.setHeader).toHaveBeenCalledWith('RateLimit-Limit', '8');
expect(res.setHeader).toHaveBeenCalledWith('RateLimit-Remaining', '7');
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Penalty', 'none');
});

it('returns a graduated throttle response after repeated violations', () => {
let res = makeResponse();
for (let i = 0; i < 10; i += 1) {
res = runLimiter(
{
path: '/submit',
params: {},
query: {},
body: { operation: 'borrow', userAddress: 'GBORROWER' },
headers: {},
ip: '127.0.0.1',
},
makeResponse()
);
}

expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ operation: 'borrow', penalty: 'throttle' })
);
expect(getSensitiveRateLimitAnalytics()[0]).toEqual(
expect.objectContaining({ operation: 'borrow', userId: 'GBORROWER' })
);
});

it('bypasses limits for trusted institutional users', () => {
process.env.RATE_LIMIT_TRUSTED_USERS = 'GINSTITUTION';
const res = runLimiter({
path: '/prepare/withdraw',
params: {},
query: { userAddress: 'GINSTITUTION' },
body: {},
headers: {},
ip: '127.0.0.1',
});

expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Bypass', 'trusted-user');
expect(res.status).not.toHaveBeenCalled();
});
});
Loading