Skip to content

Commit 7fa2866

Browse files
Merge pull request #955 from Sundayabel222/feat/enforce-request-signing
feat(api-server): enforce request signing on Soroban write endpoints
2 parents 32a3e57 + c3d76de commit 7fa2866

1 file changed

Lines changed: 104 additions & 11 deletions

File tree

api-server/src/main.rs

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::sync::Arc;
1313
struct AppState {
1414
schema: graphql::AtomicIpSchema,
1515
query_client: Arc<graphql::SorobanQueryClient>,
16+
rpc_client: Arc<dyn graphql::SorobanRpcClient>,
1617
ws_broadcaster: Arc<websocket::EventBroadcaster>,
1718
sse_broadcaster: Arc<events::EventBroadcaster>,
1819
health_checker: Arc<health::HealthChecker>,
@@ -190,13 +191,14 @@ async fn main() {
190191
let rpc_client: Arc<dyn graphql::SorobanRpcClient> = Arc::new(graphql::MockSorobanRpcClient::default());
191192
let query_client = Arc::new(graphql::SorobanQueryClient::new(rpc_client.clone()));
192193
let schema = graphql::build_schema_with_broadcaster(
193-
rpc_client,
194+
rpc_client.clone(),
194195
subscription_broadcaster.clone(),
195196
);
196197

197198
let state = AppState {
198199
schema,
199200
query_client,
201+
rpc_client,
200202
ws_broadcaster: Arc::new(websocket::EventBroadcaster::new()),
201203
sse_broadcaster: Arc::new(events::create_event_broadcaster().0),
202204
health_checker: Arc::new(health::HealthChecker::new()),
@@ -235,12 +237,11 @@ async fn main() {
235237
.route("/ip/verify", post(handlers::verify_commitment))
236238
.route("/ip/owner/{owner}", get(handlers::list_ip_by_owner))
237239
.route("/ip/owner/{owner}/cursor", get(handlers::list_ip_by_owner_cursor))
238-
.route("/ip/owner/{owner}/cursor", get(handlers::list_ip_by_owner_cursor))
239-
.route("/swap/initiate", post(handlers::initiate_swap))
240+
.route("/swap/initiate", post(handlers::initiate_swap).layer(signed.clone()))
240241
.route("/swap/batch-initiate", post(handlers::batch_initiate_swap))
241-
.route("/swap/{swap_id}/accept", post(handlers::accept_swap))
242-
.route("/swap/{swap_id}/reveal", post(handlers::reveal_key))
243-
.route("/swap/{swap_id}/cancel", post(handlers::cancel_swap))
242+
.route("/swap/{swap_id}/accept", post(handlers::accept_swap).layer(signed.clone()))
243+
.route("/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone()))
244+
.route("/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone()))
244245
.route("/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap))
245246
.route("/swap/{swap_id}", get(handlers::get_swap))
246247
.route("/openapi.json", get(openapi_handler))
@@ -328,6 +329,9 @@ fn build_app() -> Router {
328329
rpc_client,
329330
};
330331

332+
// #535: same signing enforcement as the production router, so the test
333+
// router exercises the identical middleware on the six write endpoints.
334+
let signed = middleware::from_fn(request_signing::verify_request_signature);
331335
Router::new()
332336
.route("/health", get(health::health_handler))
333337
.route("/health/detailed", get(health::detailed_health_handler))
@@ -339,19 +343,19 @@ fn build_app() -> Router {
339343
.route("/events", get(events_handler))
340344
.route("/batch", post(batch::batch_handler))
341345
.route("/v1/graphql", post(graphql_handler))
342-
.route("/v1/ip/commit", post(handlers::commit_ip))
346+
.route("/v1/ip/commit", post(handlers::commit_ip).layer(signed.clone()))
343347
.route("/v1/ip/{ip_id}", get(handlers::get_ip))
344-
.route("/v1/ip/transfer", post(handlers::transfer_ip))
348+
.route("/v1/ip/transfer", post(handlers::transfer_ip).layer(signed.clone()))
345349
.route("/v1/ip/verify", post(handlers::verify_commitment))
346350
.route("/v1/ip/owner/{owner}", get(handlers::list_ip_by_owner))
347351
.route("/v1/ip/owner/{owner}/cursor", get(handlers::list_ip_by_owner_cursor))
348352
.route("/v1/ip/owner/{owner}/cursor", get(handlers::list_ip_by_owner_cursor))
349353
.route("/v1/swap/initiate", post(handlers::initiate_swap))
350354
.route("/v1/swap/batch-initiate", post(handlers::batch_initiate_swap))
351355
.route("/v1/swap/bulk/initiate", post(handlers::batch_initiate_swap))
352-
.route("/v1/swap/{swap_id}/accept", post(handlers::accept_swap))
353-
.route("/v1/swap/{swap_id}/reveal", post(handlers::reveal_key))
354-
.route("/v1/swap/{swap_id}/cancel", post(handlers::cancel_swap))
356+
.route("/v1/swap/{swap_id}/accept", post(handlers::accept_swap).layer(signed.clone()))
357+
.route("/v1/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone()))
358+
.route("/v1/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone()))
355359
.route("/v1/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap))
356360
.route("/v1/swap/{swap_id}", get(handlers::get_swap))
357361
.route("/v1/webhooks", post(handlers::register_webhook))
@@ -377,6 +381,25 @@ mod tests {
377381
};
378382
use tower::ServiceExt;
379383

384+
/// Test router with an injectable RPC client, used by the swap read-path
385+
/// tests to exercise `GET /v1/swap/{id}` against a stub client.
386+
fn app_with_rpc_client(rpc_client: Arc<dyn graphql::SorobanRpcClient>) -> Router {
387+
let query_client = Arc::new(graphql::SorobanQueryClient::new(rpc_client.clone()));
388+
let schema = graphql::build_schema();
389+
let health_checker = Arc::new(health::HealthChecker::new());
390+
let state = AppState {
391+
schema,
392+
query_client,
393+
rpc_client,
394+
ws_broadcaster: Arc::new(websocket::EventBroadcaster::new()),
395+
sse_broadcaster: Arc::new(events::create_event_broadcaster().0),
396+
health_checker,
397+
};
398+
Router::new()
399+
.route("/v1/swap/{swap_id}", get(handlers::get_swap))
400+
.with_state(state)
401+
}
402+
380403
#[tokio::test]
381404
async fn test_post_without_content_type_returns_415() {
382405
let app = build_app();
@@ -1277,4 +1300,74 @@ mod tests {
12771300
.unwrap();
12781301
assert_eq!(resp.headers().get("Content-Encoding").unwrap(), "gzip");
12791302
}
1303+
1304+
// ── #535: Request-signing enforcement on Soroban write endpoints ──────────
1305+
1306+
/// Every write endpoint that submits a signed transaction to Soroban must
1307+
/// reject requests that carry no valid signature. The middleware runs
1308+
/// per-route on exactly these six endpoints — not on reads, verification,
1309+
/// or batch/bulk variants.
1310+
#[tokio::test]
1311+
async fn test_signed_write_endpoints_reject_unsigned_requests() {
1312+
let app = build_app();
1313+
let cases = [
1314+
("/v1/ip/commit", r#"{"owner":"GADDR","commitment_hash":"abc"}"#),
1315+
("/v1/ip/transfer", r#"{"ip_id":1,"new_owner":"GBUYER"}"#),
1316+
("/v1/swap/initiate", r#"{"ip_registry_id":"C1","ip_id":1,"seller":"GSELLER","price":100,"buyer":"GBUYER","token":"CTOKEN"}"#),
1317+
("/v1/swap/1/accept", r#"{"buyer":"GBUYER"}"#),
1318+
("/v1/swap/1/reveal", r#"{"caller":"GSELLER","secret":"abcd","blinding_factor":"efgh"}"#),
1319+
("/v1/swap/1/cancel", r#"{"canceller":"GSELLER"}"#),
1320+
];
1321+
for (path, body) in cases {
1322+
let resp = app
1323+
.clone()
1324+
.oneshot(
1325+
Request::builder()
1326+
.method("POST")
1327+
.uri(path)
1328+
.header("content-type", "application/json")
1329+
.body(Body::from(body.to_string()))
1330+
.unwrap(),
1331+
)
1332+
.await
1333+
.unwrap();
1334+
assert_eq!(
1335+
resp.status(),
1336+
StatusCode::UNAUTHORIZED,
1337+
"unsigned POST {path} must be rejected by request-signing middleware"
1338+
);
1339+
}
1340+
}
1341+
1342+
#[tokio::test]
1343+
async fn test_signed_write_endpoint_accepts_valid_signature() {
1344+
let app = build_app();
1345+
let method = "POST";
1346+
let path = "/v1/ip/commit";
1347+
let body = r#"{"owner":"GADDR","commitment_hash":"abc"}"#;
1348+
let now = std::time::SystemTime::now()
1349+
.duration_since(std::time::UNIX_EPOCH)
1350+
.unwrap()
1351+
.as_secs();
1352+
let body_hash = request_signing::hash_body(body.as_bytes());
1353+
let public_key = "GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJJBBQ5ECVVF7C3XVQCRWGSGAX";
1354+
let signature = request_signing::generate_signature(method, path, now, &body_hash, public_key);
1355+
let resp = app
1356+
.oneshot(
1357+
Request::builder()
1358+
.method(method)
1359+
.uri(path)
1360+
.header("content-type", "application/json")
1361+
.header("X-Signature", signature)
1362+
.header("X-Timestamp", now.to_string())
1363+
.header("X-Public-Key", public_key)
1364+
.body(Body::from(body.to_string()))
1365+
.unwrap(),
1366+
)
1367+
.await
1368+
.unwrap();
1369+
// Middleware verified the signature and let the request through; the
1370+
// commit_ip stub then returns 400 ("not yet implemented").
1371+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1372+
}
12801373
}

0 commit comments

Comments
 (0)