Skip to content

Commit cc70c00

Browse files
authored
merge: Merge pull request #391 from arunaengine/fix/blob-hardening
[fix] Blob layer hardening: framing, streams, finalization, path confinement
2 parents be4eaf5 + 281ead1 commit cc70c00

18 files changed

Lines changed: 1014 additions & 185 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ The system is organized around **realms**. A realm is an organizational trust bo
4545
### Data, metadata, and access
4646

4747
Each Aruna node exposes an **S3-compatible API**, so researchers can keep using the tools, scripts, workflow systems, and libraries they already have instead of learning a new storage protocol. Buckets are virtual collections that mix local data, replicated data, and references to remote resources. To a user, this looks like one coherent access point. Underneath, Aruna tracks where data actually lives, which permissions apply, and whether an object should be materialized locally or fetched on demand.
48+
49+
> [!NOTE]
50+
> Object keys for `PutObject`, `CreateMultipartUpload`, `UploadPart`, and `CompleteMultipartUpload` must be non-empty relative paths; they are rejected if they begin with `/`, contain an exact `..` path segment, or contain control characters.
4851
4952
Metadata is part of the core system, not an external catalog bolted on afterwards. Descriptions are stored as **RO-Crate JSON-LD**, so datasets, files, people, instruments, workflows, software, and process runs can be described in a shared format. These descriptions live in a CRDT-based triple store, which allows concurrent edits on different nodes and merges them without a single authority arbitrating the result. Management resources such as users and groups are synchronized through durable document-sync topics, which lets nodes keep working through network outages and reconcile state once they reconnect.
5053

api/src/s3/s3_service.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::s3::error::IntoS3Error;
99
use crate::s3::util::{
1010
convert_input, multipart_checksum_type_from_s3, parse_completed_part,
1111
parse_multipart_checksum_hint, parse_multipart_part_number, parse_upload_id, parse_version_id,
12-
s3_checksum_type_from_multipart,
12+
s3_checksum_type_from_multipart, validate_object_key,
1313
};
1414
use aruna_core::NodeId;
1515
use aruna_core::stream::{BackendStream, StreamError};
@@ -905,6 +905,7 @@ impl S3 for ArunaS3Service {
905905
error!(error = "Missing user context");
906906
s3_error!(UnexpectedContent, "Missing user context")
907907
})?;
908+
validate_object_key(&req.input.key)?;
908909
let bucket_info = req.extensions.get::<BucketInfo>().cloned();
909910
let checksum_request = parse_upload_checksum_request(&req.headers)?;
910911
let replication_auth = AuthContext {
@@ -964,6 +965,7 @@ impl S3 for ArunaS3Service {
964965
error!(error = "Missing user context");
965966
s3_error!(UnexpectedContent, "Missing user context")
966967
})?;
968+
validate_object_key(&req.input.key)?;
967969
let bucket_info = req.extensions.get::<BucketInfo>().cloned();
968970
let checksum_hint = parse_multipart_checksum_hint(&req.input)?;
969971

@@ -1007,6 +1009,7 @@ impl S3 for ArunaS3Service {
10071009
error!(error = "Missing user context");
10081010
s3_error!(UnexpectedContent, "Missing user context")
10091011
})?;
1012+
validate_object_key(&req.input.key)?;
10101013
let checksum_request = parse_upload_checksum_request(&req.headers)?;
10111014
let upload_id = parse_upload_id(&req.input.upload_id)?;
10121015
let body = req
@@ -1068,6 +1071,7 @@ impl S3 for ArunaS3Service {
10681071
error!(error = "Missing user context");
10691072
s3_error!(UnexpectedContent, "Missing user context")
10701073
})?;
1074+
validate_object_key(&req.input.key)?;
10711075
let bucket_info = req.extensions.get::<BucketInfo>().cloned();
10721076
let group_id = bucket_info
10731077
.as_ref()

api/src/s3/util.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::s3::auth::Action;
22
use aruna_core::stream::BackendStream;
33
use aruna_core::structs::checksum::{ChecksumAlgorithm, ExpectedChecksum};
4-
use aruna_core::structs::{MultipartChecksumType, MultipartUploadChecksumHint};
4+
use aruna_core::structs::{
5+
MultipartChecksumType, MultipartUploadChecksumHint, ensure_confined_relative_path,
6+
};
57
use aruna_operations::s3::complete_multipart_upload::CompleteMultipartPart;
68
use aruna_operations::s3::put_object::PutObjectInput as BlobPutObjectInput;
79
use base64::prelude::*;
@@ -10,6 +12,7 @@ use s3s::dto::{
1012
ChecksumType, CompletedPart, CreateMultipartUploadInput, PartNumber, PutObjectInput,
1113
};
1214
use s3s::{S3Error, S3ErrorCode, S3Result, s3_error};
15+
use std::path::Path;
1316
use ulid::Ulid;
1417

1518
pub fn get_s3_operation_permission(operation_name: &str) -> Option<Action> {
@@ -123,6 +126,15 @@ pub(crate) fn is_anonymous_object_read_operation(operation_name: &str) -> bool {
123126
matches!(operation_name, "GetObject")
124127
}
125128

129+
pub(crate) fn validate_object_key(key: &str) -> S3Result<()> {
130+
if key.is_empty() {
131+
return Err(s3_error!(InvalidArgument, "Object key must not be empty"));
132+
}
133+
134+
ensure_confined_relative_path(Path::new(key))
135+
.map_err(|err| s3_error!(InvalidArgument, "{}", err.to_string()))
136+
}
137+
126138
pub(crate) fn convert_input(mut input: PutObjectInput) -> S3Result<BlobPutObjectInput, S3Error> {
127139
match input.body.take() {
128140
None => Err(s3_error!(InvalidRequest, "Missing body")),
@@ -264,11 +276,43 @@ pub(crate) fn checksum_algorithm_from_s3(
264276
mod tests {
265277
use super::{
266278
get_s3_operation_permission, is_anonymous_object_read_operation,
267-
parse_multipart_part_number,
279+
parse_multipart_part_number, validate_object_key,
268280
};
269281
use crate::s3::auth::Action;
270282
use s3s::S3ErrorCode;
271283

284+
#[test]
285+
fn validate_object_key_accepts_ordinary_keys() {
286+
for key in ["object.bin", "nested/path/object.bin", "a.b..c/keep..dots"] {
287+
assert!(
288+
validate_object_key(key).is_ok(),
289+
"key {key:?} must be valid"
290+
);
291+
}
292+
}
293+
294+
#[test]
295+
fn validate_object_key_rejects_traversal_and_control_keys() {
296+
let cases = [
297+
"",
298+
"/absolute/key",
299+
"../escape",
300+
"../../etc/passwd",
301+
"nested/../../escape",
302+
"trailing/..",
303+
"with\u{0000}null",
304+
"with\u{007f}delete",
305+
"with\nnewline",
306+
];
307+
for key in cases {
308+
assert_eq!(
309+
*validate_object_key(key).unwrap_err().code(),
310+
S3ErrorCode::InvalidArgument,
311+
"key {key:?} must be rejected"
312+
);
313+
}
314+
}
315+
272316
#[test]
273317
fn anonymous_public_access_only_allows_get_object() {
274318
assert!(is_anonymous_object_read_operation("GetObject"));

blob/src/bao_tree.rs

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use crate::error::BlobLibError;
22
use crate::hash::Hasher;
3+
use crate::opendal::abort_partial_writer;
34
use aruna_net::streams::{RecvStream, SendStream};
45
use bytes::Bytes;
5-
use futures::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
6+
use futures::{AsyncReadExt, AsyncSeekExt};
67
use iroh_io::{AsyncSliceReader, AsyncSliceWriter, AsyncStreamReader, AsyncStreamWriter};
7-
use opendal::{FuturesAsyncReader, FuturesAsyncWriter, Operator};
8+
use opendal::{FuturesAsyncReader, Operator};
89
use std::{future::Future, io, time::Duration};
910
use tokio::io::AsyncWriteExt as TokioAsyncWriteExt;
1011
use tokio::time::timeout;
@@ -147,47 +148,46 @@ impl AsyncStreamReader for RecvStreamWrapper<'_> {
147148
}
148149
}
149150

150-
// ----- FuturesAsyncReader/FuturesAsyncWriter impls for bao_tree ----------
151+
// ----- FuturesAsyncReader/opendal Writer impls for bao_tree ----------
151152
pub struct OpenDalWriter {
152-
pub writer: FuturesAsyncWriter,
153-
pub len: u64,
153+
writer: opendal::Writer,
154+
operator: Operator,
155+
storage_path: String,
154156
pub hasher: Hasher,
155-
pub idle_timeout: Duration,
157+
idle_timeout: Duration,
156158
}
157159

158160
impl AsyncSliceWriter for OpenDalWriter {
159161
async fn write_at(&mut self, offset: u64, data: &[u8]) -> std::io::Result<()> {
160162
debug!("[OpenDalWriter] Try to write data with offset {}", offset);
161163
with_transfer_idle_timeout(
162-
self.writer.write_all(data),
164+
async {
165+
self.writer
166+
.write(data.to_vec())
167+
.await
168+
.map_err(io::Error::from)
169+
},
163170
self.idle_timeout,
164171
"writing replicated chunk to backend storage",
165172
)
166173
.await?;
167-
with_transfer_idle_timeout(
168-
self.writer.flush(),
169-
self.idle_timeout,
170-
"flushing replicated chunk to backend storage",
171-
)
172-
.await?;
173174
self.hasher.update(data);
174175
Ok(())
175176
}
176177

177178
async fn write_bytes_at(&mut self, offset: u64, data: Bytes) -> std::io::Result<()> {
178179
debug!("[OpenDalWriter] Try to write bytes with offset {}", offset);
179180
with_transfer_idle_timeout(
180-
self.writer.write_all(&data),
181+
async {
182+
self.writer
183+
.write(data.clone())
184+
.await
185+
.map_err(io::Error::from)
186+
},
181187
self.idle_timeout,
182188
"writing replicated chunk to backend storage",
183189
)
184190
.await?;
185-
with_transfer_idle_timeout(
186-
self.writer.flush(),
187-
self.idle_timeout,
188-
"flushing replicated chunk to backend storage",
189-
)
190-
.await?;
191191
self.hasher.update(&data);
192192
Ok(())
193193
}
@@ -198,12 +198,6 @@ impl AsyncSliceWriter for OpenDalWriter {
198198
}
199199

200200
async fn sync(&mut self) -> std::io::Result<()> {
201-
with_transfer_idle_timeout(
202-
self.writer.flush(),
203-
self.idle_timeout,
204-
"flushing replicated chunk to backend storage",
205-
)
206-
.await?;
207201
Ok(())
208202
}
209203
}
@@ -212,19 +206,40 @@ impl OpenDalWriter {
212206
pub async fn new(
213207
operator: &Operator,
214208
storage_path: &str,
215-
blob_size: u64,
216209
idle_timeout: Duration,
217210
) -> Result<Self, BlobLibError> {
218211
Ok(Self {
219-
writer: operator
220-
.writer(storage_path)
221-
.await?
222-
.into_futures_async_write(),
223-
len: blob_size,
212+
writer: operator.writer(storage_path).await?,
213+
operator: operator.clone(),
214+
storage_path: storage_path.to_string(),
224215
hasher: Hasher::new(),
225216
idle_timeout,
226217
})
227218
}
219+
220+
pub async fn finalize(mut self) -> std::io::Result<()> {
221+
let close_result = with_transfer_idle_timeout(
222+
async {
223+
self.writer
224+
.close()
225+
.await
226+
.map(|_| ())
227+
.map_err(io::Error::from)
228+
},
229+
self.idle_timeout,
230+
"finalizing replicated blob in backend storage",
231+
)
232+
.await;
233+
if let Err(err) = close_result {
234+
abort_partial_writer(&mut self.writer, &self.operator, &self.storage_path).await;
235+
return Err(err);
236+
}
237+
Ok(())
238+
}
239+
240+
pub async fn abort(mut self) {
241+
abort_partial_writer(&mut self.writer, &self.operator, &self.storage_path).await;
242+
}
228243
}
229244

230245
pub struct OpenDalReader {

blob/src/blob/backend.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use aruna_core::errors::{BlobError, ConversionError};
77
use aruna_core::events::{Event, StorageEvent};
88
use aruna_core::handle::Handle;
99
use aruna_core::keyspaces::BUCKET_STATS_DB;
10-
use aruna_core::structs::{Backend, BackendBucket, BackendLocation};
10+
use aruna_core::structs::{Backend, BackendBucket, BackendLocation, ensure_confined_relative_path};
1111
use opendal::Operator;
1212
use std::path::PathBuf;
1313
use ulid::Ulid;
@@ -198,9 +198,9 @@ pub(super) fn build_backend_path(
198198
key: &str,
199199
ulid: Ulid,
200200
) -> Result<String, ConversionError> {
201-
PathBuf::from(bucket)
202-
.join(format!("{}_{}", key, ulid))
203-
.into_os_string()
201+
let path = PathBuf::from(bucket).join(format!("{}_{}", key, ulid));
202+
ensure_confined_relative_path(&path)?;
203+
path.into_os_string()
204204
.into_string()
205205
.map_err(|_| ConversionError::OsStringError)
206206
}
@@ -227,9 +227,9 @@ pub(super) fn rebuild_backend_path(
227227
.rsplit_once('_')
228228
.map_or(file_name, |(base, _)| base);
229229

230-
parent
231-
.join(format!("{}_{}", base_name, ulid))
232-
.into_os_string()
230+
let path = parent.join(format!("{}_{}", base_name, ulid));
231+
ensure_confined_relative_path(&path)?;
232+
path.into_os_string()
233233
.into_string()
234234
.map_err(|_| ConversionError::OsStringError)
235235
}

blob/src/blob/control_plane.rs

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
use super::{BlobEvent, BlobHandler, ControlPlaneTimeoutKind};
2+
use crate::framing::{MAX_CONTROL_PLANE_FRAME, read_frame, write_frame};
23
use crate::messages::{MessageType, ReplicationMessage};
34
use aruna_core::errors::BlobError;
45
use aruna_core::structs::BackendLocation;
56
use aruna_net::streams::{RecvStream, SendStream};
67
use std::future::Future;
78
use std::time::Duration;
8-
use tokio::io::{AsyncReadExt as TokioAsyncReadExt, AsyncWriteExt as TokioAsyncWriteExt};
99
use tokio::time::timeout;
1010
use ulid::Ulid;
1111

12-
const MAX_CONTROL_PLANE_MESSAGE_SIZE: usize = 128 * 1024 * 1024;
13-
1412
pub(super) fn control_plane_timeout_event(
1513
kind: ControlPlaneTimeoutKind,
1614
action: &'static str,
@@ -84,19 +82,8 @@ pub(super) async fn send_framed_message_with_timeout(
8482
timeout_duration: Duration,
8583
action: &'static str,
8684
) -> Result<(), BlobEvent> {
87-
if payload.len() > MAX_CONTROL_PLANE_MESSAGE_SIZE {
88-
return Err(BlobEvent::Error(BlobError::WriteError(format!(
89-
"control-plane message exceeds maximum size: {} bytes",
90-
payload.len()
91-
))));
92-
}
93-
9485
match with_control_plane_timeout(
95-
async {
96-
TokioAsyncWriteExt::write_u32(sender, payload.len() as u32).await?;
97-
TokioAsyncWriteExt::write_all(sender, payload).await?;
98-
TokioAsyncWriteExt::flush(sender).await
99-
},
86+
write_frame(sender, payload, MAX_CONTROL_PLANE_FRAME),
10087
timeout_duration,
10188
ControlPlaneTimeoutKind::Write,
10289
action,
@@ -115,18 +102,7 @@ pub(super) async fn read_framed_message_with_timeout(
115102
action: &'static str,
116103
) -> Result<Vec<u8>, BlobEvent> {
117104
match with_control_plane_timeout(
118-
async {
119-
let msg_len = TokioAsyncReadExt::read_u32(receiver).await?;
120-
if msg_len as usize > MAX_CONTROL_PLANE_MESSAGE_SIZE {
121-
return Err(std::io::Error::new(
122-
std::io::ErrorKind::InvalidData,
123-
format!("control-plane frame too large: {msg_len} bytes"),
124-
));
125-
}
126-
let mut buf = vec![0; msg_len as usize];
127-
TokioAsyncReadExt::read_exact(receiver, &mut buf).await?;
128-
Ok::<Vec<u8>, std::io::Error>(buf)
129-
},
105+
read_frame(receiver, MAX_CONTROL_PLANE_FRAME),
130106
timeout_duration,
131107
ControlPlaneTimeoutKind::Read,
132108
action,

0 commit comments

Comments
 (0)