Skip to content

Commit 6460b24

Browse files
authored
fix(file): raise paste-image body limit to 100MB (#232)
- Added DefaultBodyLimit(10MB) to global router - Set 50MB limit for /api/fs/upload via RequestBodyLimitLayer - Set 100MB limit for /api/fs/paste-image endpoint - Introduced IPasteImageHandler trait with BodyBytes extractor - Added PasteImageRequest validation with max 10MB base64 size - Added E2E test for paste-image body limit enforcement
1 parent 1ebb78a commit 6460b24

8 files changed

Lines changed: 691 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.21" }
6161
tokio = { version = "1", features = ["full"] }
6262
axum = { version = "0.8", features = ["multipart", "ws"] }
6363
tower = { version = "0.5" }
64-
tower-http = { version = "0.6", features = ["cors", "trace"] }
64+
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
6565
http = "1"
6666

6767
# Serialization

crates/aionui-app/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod team_stdio;
66

77
use std::sync::Arc;
88

9+
use axum::extract::DefaultBodyLimit;
910
use axum::http::Method;
1011
use axum::middleware::from_fn_with_state;
1112
use axum::routing::get;
@@ -607,6 +608,11 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates
607608
.merge(public_assets)
608609
.layer(middleware::from_fn(security_headers_middleware));
609610

611+
// Raise the default request body limit from axum's 2MB default to
612+
// `BODY_LIMIT` (10MB). Routes that need a larger cap (e.g. `/api/fs/upload`)
613+
// disable this default and install their own `RequestBodyLimitLayer`.
614+
let router = router.layer(DefaultBodyLimit::max(aionui_common::constants::BODY_LIMIT));
615+
610616
let router = with_access_log(router);
611617

612618
if services.local {

crates/aionui-app/tests/file_e2e.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ async fn fs_endpoints_require_auth() {
2525
"/api/fs/remove",
2626
"/api/fs/rename",
2727
"/api/fs/temp",
28+
"/api/fs/upload",
2829
"/api/fs/image-base64",
2930
"/api/fs/fetch-remote-image",
3031
"/api/fs/zip",
@@ -775,3 +776,178 @@ async fn path_traversal_rejected() {
775776
// Should be rejected (400 bad request)
776777
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
777778
}
779+
780+
// ===========================================================================
781+
// /api/fs/upload — multipart upload
782+
// ===========================================================================
783+
784+
struct UploadMultipart {
785+
boundary: String,
786+
parts: Vec<u8>,
787+
}
788+
789+
impl UploadMultipart {
790+
fn new() -> Self {
791+
Self {
792+
boundary: "----TestBoundaryFsUpload9XyZ".to_owned(),
793+
parts: Vec::new(),
794+
}
795+
}
796+
797+
fn add_text(mut self, name: &str, value: &str) -> Self {
798+
self.parts
799+
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
800+
self.parts
801+
.extend_from_slice(format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes());
802+
self.parts.extend_from_slice(value.as_bytes());
803+
self.parts.extend_from_slice(b"\r\n");
804+
self
805+
}
806+
807+
fn add_file(mut self, name: &str, filename: &str, mime: &str, data: &[u8]) -> Self {
808+
self.parts
809+
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
810+
self.parts.extend_from_slice(
811+
format!("Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n").as_bytes(),
812+
);
813+
self.parts
814+
.extend_from_slice(format!("Content-Type: {mime}\r\n\r\n").as_bytes());
815+
self.parts.extend_from_slice(data);
816+
self.parts.extend_from_slice(b"\r\n");
817+
self
818+
}
819+
820+
fn build(mut self) -> (String, Vec<u8>) {
821+
self.parts
822+
.extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes());
823+
let content_type = format!("multipart/form-data; boundary={}", self.boundary);
824+
(content_type, self.parts)
825+
}
826+
}
827+
828+
fn upload_request(content_type: &str, body: Vec<u8>, token: &str, csrf: &str) -> axum::http::Request<axum::body::Body> {
829+
let content_length = body.len();
830+
axum::http::Request::builder()
831+
.method("POST")
832+
.uri("/api/fs/upload")
833+
.header("content-type", content_type)
834+
.header("content-length", content_length)
835+
.header("authorization", format!("Bearer {token}"))
836+
.header("x-csrf-token", csrf)
837+
.header("cookie", format!("aionui-csrf-token={csrf}"))
838+
.body(axum::body::Body::from(body))
839+
.unwrap()
840+
}
841+
842+
#[tokio::test]
843+
async fn upload_accepts_small_png_and_returns_readable_path() {
844+
let (mut app, services) = build_app().await;
845+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
846+
847+
// Minimal valid 1x1 PNG (67 bytes).
848+
let png_bytes: Vec<u8> = vec![
849+
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
850+
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
851+
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2,
852+
0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
853+
];
854+
855+
let conv_id = format!("conv-upload-{}", std::process::id());
856+
let (content_type, body) = UploadMultipart::new()
857+
.add_file("file", "paste.png", "image/png", &png_bytes)
858+
.add_text("conversation_id", &conv_id)
859+
.build();
860+
861+
let req = upload_request(&content_type, body, &token, &csrf);
862+
let resp = app.oneshot(req).await.unwrap();
863+
assert_eq!(resp.status(), StatusCode::OK);
864+
865+
let json = body_json(resp).await;
866+
assert_eq!(json["success"], true);
867+
let path = json["data"].as_str().expect("data should be a string path");
868+
let p = std::path::Path::new(path);
869+
assert!(p.is_absolute(), "returned path must be absolute: {path}");
870+
assert_eq!(p.file_name().unwrap().to_string_lossy(), "paste.png");
871+
// conversation_id routing produced a sub-directory of that name.
872+
assert_eq!(p.parent().unwrap().file_name().unwrap().to_string_lossy(), conv_id);
873+
// File contents must match what we uploaded.
874+
let on_disk = std::fs::read(p).expect("uploaded file should be readable");
875+
assert_eq!(on_disk, png_bytes);
876+
877+
// Cleanup.
878+
let _ = std::fs::remove_file(p);
879+
let _ = std::fs::remove_dir(p.parent().unwrap());
880+
}
881+
882+
#[tokio::test]
883+
async fn upload_uses_content_disposition_filename_when_file_name_missing() {
884+
let (mut app, services) = build_app().await;
885+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
886+
887+
let bytes = b"hello upload".to_vec();
888+
let unique = format!("dispo-{}.bin", std::process::id());
889+
let (content_type, body) = UploadMultipart::new()
890+
.add_file("file", &unique, "application/octet-stream", &bytes)
891+
.build();
892+
893+
let req = upload_request(&content_type, body, &token, &csrf);
894+
let resp = app.oneshot(req).await.unwrap();
895+
assert_eq!(resp.status(), StatusCode::OK);
896+
897+
let json = body_json(resp).await;
898+
let path = json["data"].as_str().unwrap();
899+
assert!(path.ends_with(&unique));
900+
let _ = std::fs::remove_file(path);
901+
}
902+
903+
#[tokio::test]
904+
async fn upload_prefers_explicit_file_name_field_over_dispo() {
905+
let (mut app, services) = build_app().await;
906+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
907+
908+
let bytes = b"pref".to_vec();
909+
let explicit = format!("explicit-{}.bin", std::process::id());
910+
let (content_type, body) = UploadMultipart::new()
911+
.add_file("file", "dispo.bin", "application/octet-stream", &bytes)
912+
.add_text("file_name", &explicit)
913+
.build();
914+
915+
let req = upload_request(&content_type, body, &token, &csrf);
916+
let resp = app.oneshot(req).await.unwrap();
917+
assert_eq!(resp.status(), StatusCode::OK);
918+
919+
let json = body_json(resp).await;
920+
let path = json["data"].as_str().unwrap();
921+
assert!(path.ends_with(&explicit), "expected filename {explicit}, got {path}");
922+
let _ = std::fs::remove_file(path);
923+
}
924+
925+
#[tokio::test]
926+
async fn upload_missing_file_field_returns_400() {
927+
let (mut app, services) = build_app().await;
928+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
929+
930+
let (content_type, body) = UploadMultipart::new().add_text("file_name", "ignored.png").build();
931+
932+
let req = upload_request(&content_type, body, &token, &csrf);
933+
let resp = app.oneshot(req).await.unwrap();
934+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
935+
let json = body_json(resp).await;
936+
assert_eq!(json["success"], false);
937+
}
938+
939+
#[tokio::test]
940+
async fn upload_body_exceeding_30mb_returns_413() {
941+
let (mut app, services) = build_app().await;
942+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
943+
944+
// 31 MB payload comfortably exceeds UPLOAD_MAX_SIZE (30 MB).
945+
let big = vec![0u8; 31 * 1024 * 1024];
946+
let (content_type, body) = UploadMultipart::new()
947+
.add_file("file", "big.bin", "application/octet-stream", &big)
948+
.build();
949+
950+
let req = upload_request(&content_type, body, &token, &csrf);
951+
let resp = app.oneshot(req).await.unwrap();
952+
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
953+
}

crates/aionui-file/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ aionui-realtime.workspace = true
1010
async-trait.workspace = true
1111
axum.workspace = true
1212
tokio.workspace = true
13+
tower-http.workspace = true
1314
walkdir.workspace = true
1415
ignore.workspace = true
1516
notify.workspace = true

0 commit comments

Comments
 (0)