Skip to content

Commit 1577bbb

Browse files
committed
feat(write-once): also cover S3 presigned URLs
ensure_write_once only triggered on temp_url_expires (Swift). In dual mode an S3 presigned PUT (x-amz-expires) was not protected. Detection now covers both, case-insensitively.
1 parent ea0833e commit 1577bbb

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

src/http/middlewares/mod.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ pub async fn ensure_write_once(
1717
) -> Result<ServiceResponse<impl MessageBody>, Error> {
1818
let uri = req.uri();
1919

20-
let user_facing_uri = uri
21-
.query()
22-
.is_some_and(|query| query.contains("temp_url_expires"));
20+
// Only guard presigned/user-facing writes: Swift TempURL (temp_url_expires)
21+
// and S3 presigned URLs (x-amz-expires). Both flavors are covered so
22+
// write-once holds in dual mode too.
23+
let user_facing_uri = uri.query().is_some_and(|query| {
24+
let query = query.to_ascii_lowercase();
25+
query.contains("temp_url_expires") || query.contains("x-amz-expires")
26+
});
2327

2428
if !user_facing_uri {
2529
return next.call(req).await;

tests/ensure_write_once.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,62 @@ mod tests {
9393
}
9494
}
9595

96+
#[actix_web::test]
97+
#[serial(servers)]
98+
async fn test_ensure_write_once_blocks_s3_presigned_url() {
99+
let _redis_process = launch_redis_with_delay();
100+
101+
let config = RedisConfig {
102+
url: Url::parse("redis://127.0.0.1:5555").unwrap(),
103+
..RedisConfig::default()
104+
};
105+
let redis_pool = configure_redis_pool(config).await;
106+
107+
let mut actix_app = App::new().service(
108+
resource("/s3-path")
109+
.guard(Get())
110+
.wrap(from_fn(ensure_write_once))
111+
.to(mock_success),
112+
);
113+
114+
actix_app = actix_app.app_data(web::Data::new(WriteOnceService::new(redis_pool.clone())));
115+
116+
match redis_pool.get().await {
117+
Ok(mut conn) => {
118+
let _: () = conn
119+
.del(WriteOnceService::hash_key("/s3-path"))
120+
.await
121+
.unwrap();
122+
}
123+
Err(_err) => panic!("Failed to get Redis connection"),
124+
}
125+
126+
let app = test::init_service(actix_app).await;
127+
128+
// First request: an S3 presigned URL (x-amz-expires) should pass and lock
129+
let req = test::TestRequest::get()
130+
.uri("/s3-path?X-Amz-Expires=60&X-Amz-Signature=abc")
131+
.to_request();
132+
let resp = test::call_service(&app, req).await;
133+
assert_eq!(resp.status(), 200);
134+
135+
// Subsequent presigned writes on the same path are denied, whatever the
136+
// parameter casing.
137+
let bypass_attempts = [
138+
"/s3-path?X-Amz-Expires=60&X-Amz-Signature=abc", // identical
139+
"/s3-path?x-amz-expires=60&x-amz-signature=def", // lowercased
140+
];
141+
142+
for uri in bypass_attempts {
143+
let req = test::TestRequest::get().uri(uri).to_request();
144+
let resp = test::try_call_service(&app, req).await;
145+
match resp {
146+
Ok(resp) => panic!("Expected 403 for {}, got {}", uri, resp.status()),
147+
Err(err) => assert_eq!(err.error_response().status(), 403),
148+
}
149+
}
150+
}
151+
96152
#[actix_web::test]
97153
#[serial(servers)]
98154
async fn test_ensure_write_once_skips_private_uri() {

0 commit comments

Comments
 (0)