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
19 changes: 19 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.0.0",
"fast-check": "^4.8.0",
"helmet": "^8.3.0",
"ioredis": "^5.10.1",
"node-cache": "^5.1.2",
"pg": "^8.22.0",
Expand Down
23 changes: 23 additions & 0 deletions backend/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ describe('Backend API', () => {
});
});

// ─── Security Headers (Issue #1676) ──────────────────────────────────────

describe('security headers', () => {
it('sets security headers on /health', async () => {
const response = await request(app).get('/health');

expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['x-frame-options']).toBe('DENY');
expect(response.headers['content-security-policy']).toBeDefined();

// HSTS is production-only; it must not be set in the test environment.
expect(response.headers['strict-transport-security']).toBeUndefined();
});

it('CSP still allows the Swagger UI docs page to load', async () => {
const response = await request(app).get('/docs');

expect(response.status).toBe(200);
expect(response.headers['content-security-policy']).toBeDefined();
expect(response.text).toContain('swagger');
});
});

// ─── Readiness Endpoint Tests ────────────────────────────────────────────

describe('GET /ready', () => {
Expand Down
45 changes: 45 additions & 0 deletions backend/src/__tests__/gracefulShutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,49 @@ describe('GracefulShutdownHandler', () => {
expect(handler.isShuttingDown()).toBe(true);
expect(exitSpy).toHaveBeenCalledWith(0);
});

// Issue #1675: one shutdown path — the handler registered in index.ts runs
// outbox, then DB cleanup, in a documented order after the HTTP server has
// drained in-flight requests.
it('runs onShutdown tasks in documented order after the server drains', async () => {
const server = http.createServer((_req, res) => {
// Hold the socket open briefly so the drain step is exercised.
setTimeout(() => {
res.writeHead(200);
res.end('ok');
}, 50);
});
await listen(server);

const order: string[] = [];
const handler = new GracefulShutdownHandler(2000, 50);
handler.register(server);

// Documented order (matches index.ts): background jobs/outbox first, DB last.
handler.onShutdown(async () => {
order.push('outbox-stop');
});
handler.onShutdown(async () => {
order.push('db-disconnect');
});

let serverClosed = false;
await new Promise<void>((resolve) => {
server.close(() => {
serverClosed = true;
resolve();
});
});

process.emit('SIGTERM');

await new Promise((r) => setTimeout(r, 200));

expect(handler.isShuttingDown()).toBe(true);
// Server closes before cleanup tasks run.
expect(serverClosed).toBe(true);
// Cleanup tasks run in registration order: outbox before DB.
expect(order).toEqual(['outbox-stop', 'db-disconnect']);
expect(exitSpy).toHaveBeenCalledWith(0);
});
});
38 changes: 25 additions & 13 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ initTracing();

import 'express-async-errors';
import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express';
import helmet from 'helmet';
import { loginHandler, nonceHandler, refreshHandler, requireAuth, revokeAccessToken, revokeAllAccessTokens, revokeCurrentSession, revokeAllSessions } from './auth';
import vaultListRouter from './routes/vaults';
import {
Expand Down Expand Up @@ -642,6 +643,29 @@ async function handleTransactionExport(req: Request, res: Response): Promise<voi

// ─── Middleware ──────────────────────────────────────────────────────────────

// Security headers (Issue #1676). Applied before routes so every response,
// including Swagger UI, receives them. HSTS is production-only.
app.use(
helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
},
},
// HSTS only tells browsers to use HTTPS in production; in development and
// test it must not be set (it would break local HTTP tooling).
strictTransportSecurity:
nodeEnv === 'production'
? { includeSubDomains: true, maxAge: 31536000, preload: true }
: false,
})
);

app.use(tieredJsonBodyParser());

// CORS configuration (restricted origins)
Expand Down Expand Up @@ -4839,7 +4863,6 @@ if (process.env.NODE_ENV !== 'test') {
error: err instanceof Error ? err.message : String(err),
});
});
eventOutboxService.start();

// Register graceful shutdown for the outbox processor so pending events
// are not abandoned when the process receives a termination signal.
Expand All @@ -4849,18 +4872,7 @@ if (process.env.NODE_ENV !== 'test') {
shutdownHandler.onShutdown(async () => {
eventOutboxService.stop();
});
const shutdownController = new AbortController();
const shutdownSignal = shutdownController.signal;
eventOutboxService.start({ signal: shutdownSignal });

// Register graceful shutdown for the outbox processor: abort in-flight work
// and wait for the poller to drain before the process tears down resources.
const stopOutbox = () => {
shutdownController.abort();
void eventOutboxService.stop();
};
process.on('SIGTERM', stopOutbox);
process.on('SIGINT', stopOutbox);
eventOutboxService.start();
}


Expand Down
5 changes: 4 additions & 1 deletion contracts/vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ pub enum VaultError {
ExceedsStrategyCap = 10,
/// Strategy allocation exceeds configured risk threshold.
ExceedsRiskThreshold = 11,
/// Withdrawal blocked due to active deposit cooldown.
/// Withdrawal blocked due to an active deposit cooldown, or because the
/// user already has a pending large withdrawal that must be executed or
/// cancelled with `cancel_withdrawal` before another can be queued
/// (Issue #1680).
WithdrawalCooldownActive = 12,
/// Requested storage migration target is older than the current stored version.
///
Expand Down
75 changes: 45 additions & 30 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,11 +858,6 @@ impl YieldVault {
.get(&DataKey::LastStrategySwitchTime)
.unwrap_or(0);
let earliest_switch = last_switch.saturating_add(cooldown);
if env.ledger().timestamp() < earliest_switch {
let earliest_switch = last_switch.checked_add(cooldown).expect("overflow");
if now < earliest_switch {
return Err(VaultError::AdminParamChangeTooSoon);
}
}

let current_strategy = Self::strategy(env.clone());
Expand Down Expand Up @@ -1476,43 +1471,18 @@ impl YieldVault {
let oracle_client = OracleClient::new(&env, &oracle_addr);
let price_data = oracle_client.get_price(&token, &token);
let max_age = Self::oracle_heartbeat(env.clone());
let last: Option<oracle::PriceData> = env
.storage()
.instance()
.get(&DataKeyExt::Risk(RiskExtKey::LastPx));
let _last_price = Self::last_oracle_price(&env);
let last_price = Self::last_oracle_price(&env);
oracle::OracleValidator::validate_price_data(
&env,
&price_data,
max_age,
Some(oracle::MAX_PRICE_DEVIATION_BPS),
last.as_ref(),
last_price.as_ref(),
)
.map_err(|_| VaultError::OracleValidationFailed)?;
Self::set_last_oracle_price(&env, &price_data);
}
let token = Self::token(env.clone());
if Self::is_oracle_enabled(env.clone()) {
if let Some(oracle_addr) = Self::price_oracle(env.clone()) {
let oracle_client = OracleClient::new(&env, &oracle_addr);
let price_data = oracle_client.get_price(&token, &token);
let max_age = Self::oracle_heartbeat(env.clone());
let last_price = Self::last_oracle_price(&env);
oracle::OracleValidator::validate_price_data(
&env,
&price_data,
max_age,
Some(oracle::MAX_PRICE_DEVIATION_BPS),
last_price.as_ref(),
)
.map_err(|_| VaultError::OracleValidationFailed)?;
Self::set_last_oracle_price(&env, &price_data);
}
}

let strategy_assets = if let Some(strategy_addr) = Self::strategy(env.clone()) {
Self::require_valid_strategy_response(&env, &strategy_addr, &token)
} else {
0
Expand Down Expand Up @@ -2850,6 +2820,10 @@ impl YieldVault {
/// is created with a 24-hour timelock. Call `execute_withdrawal` after the
/// timelock expires to complete the transfer.
///
/// A user with a pending large withdrawal cannot queue another one: the
/// request is rejected until the pending withdrawal is executed or
/// cancelled with [`Self::cancel_withdrawal`] (#1680).
///
/// ### Parameters
/// * `user` - The share holder (requires auth).
/// * `shares` - The number of shares to burn.
Expand Down Expand Up @@ -2907,6 +2881,14 @@ impl YieldVault {
.map_err(|_| VaultError::MathOverflow)?;

if assets_to_return > threshold {
// A user may not queue a second large withdrawal while one is
// already pending (#1680): the pending request must be executed
// or cancelled first, so we never silently overwrite it and
// restart its 24-hour timelock.
if env.storage().instance().has(&DataKey::PendingWithdrawal(user.clone())) {
return Err(VaultError::WithdrawalCooldownActive);
}

// Create a pending withdrawal with a 24-hour timelock
let unlock_ts = env
.ledger()
Expand Down Expand Up @@ -2971,6 +2953,39 @@ impl YieldVault {
Self::do_withdraw(&env, &mut state, user, pending.shares, assets_to_return)
}

/// Returns the pending large withdrawal for `user`, if one exists.
pub fn pending_withdrawal(env: Env, user: Address) -> Option<PendingWithdrawal> {
env.storage().instance().get(&DataKey::PendingWithdrawal(user))
}

/// Cancel a pending large withdrawal before its timelock expires.
///
/// The caller must own the pending withdrawal (`user.require_auth()`).
/// Cancelling removes the pending entry so the user can submit a new
/// withdrawal instead of waiting out the 24-hour timelock (#1681).
///
/// ### Errors
/// * [`VaultError::NoPendingWithdrawal`] - no pending withdrawal exists
/// for `user`.
pub fn cancel_withdrawal(env: Env, user: Address) -> Result<(), VaultError> {
user.require_auth();

let pending: PendingWithdrawal = env
.storage()
.instance()
.get(&DataKey::PendingWithdrawal(user.clone()))
.ok_or(VaultError::NoPendingWithdrawal)?;

env.storage()
.instance()
.remove(&DataKey::PendingWithdrawal(user.clone()));

env.events()
.publish((symbol_short!("pndwcn"), user), (pending.shares, pending.unlock_timestamp));

Ok(())
}

/// Internal: burns shares, transfers assets, updates state.
fn do_withdraw(
env: &Env,
Expand Down
16 changes: 5 additions & 11 deletions contracts/vault/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@
//! - Migration-safe updates: old and new signer sets coexist during transition
//! - Operations require threshold signatures from the current active set

use soroban_sdk::{Address, Vec};
use crate::VaultError;
use crate::VaultError;
use soroban_sdk::{Address, Env, Map, Vec};
use crate::VaultError;

/// Verifies that the caller is the admin
///
Expand Down Expand Up @@ -49,15 +47,11 @@ pub fn require_pauser_or_admin_auth(
caller.require_auth();
let is_admin = caller == admin;
let is_pauser = pauser.map_or(false, |p| caller == p);
if !(is_admin || is_pauser) {
return Err(VaultError::RescueUnauthorized);
if is_admin || is_pauser {
Ok(())
} else {
Err(VaultError::RescueUnauthorized)
}
Ok(())
let is_pauser = pauser == Some(caller);
assert!(
is_admin || is_pauser,
"unauthorized: caller must be admin or pauser"
);
}

/// Multi-signer threshold validator for governance operations.
Expand Down
Loading
Loading