diff --git a/.gitignore b/.gitignore index 9cf4a906..0902ee96 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ xbed.sql **/sqlpage.bin node_modules/ sqlpage/sqlpage.db +tests_uploads/ diff --git a/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql b/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql index 4b75fd18..b45943aa 100644 --- a/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql +++ b/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql @@ -62,4 +62,14 @@ VALUES ( 'Optional. Comma-separated list of allowed file extensions. By default: jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov. Changing this may be dangerous ! If you add "sql", "svg" or "html" to the list, an attacker could execute arbitrary SQL queries on your database, or impersonate other users.', 'TEXT' + ), + ( + 'persist_uploaded_file', + 4, + 'mode', + 'Optional. Unix permissions to set on the file, in octal notation. By default, the file will be saved with "600" (read/write for the owner only). +Octal notation works by using three digits from 0 to 7: the first for the owner, the second for the group, and the third for others. +For example, "644" means read/write for the owner, and read-only for others. +[Learn more about numeric notation for file-system permissions on Wikipedia](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation).', + 'TEXT' ); diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index a41b18a1..743d0b54 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -42,7 +42,7 @@ super::function_definition_macro::sqlpage_functions! { link(file: Cow, parameters: Option>, hash: Option>); path((&RequestInfo)); - persist_uploaded_file((&RequestInfo), field_name: Cow, folder: Option>, allowed_extensions: Option>); + persist_uploaded_file((&RequestInfo), field_name: Cow, folder: Option>, allowed_extensions: Option>, mode: Option>); protocol((&RequestInfo)); random_string(string_length: SqlPageFunctionParam); @@ -420,6 +420,7 @@ async fn persist_uploaded_file<'a>( field_name: Cow<'a, str>, folder: Option>, allowed_extensions: Option>, + mode: Option>, ) -> anyhow::Result> { let folder = folder.unwrap_or(Cow::Borrowed("uploads")); let allowed_extensions_str = @@ -456,6 +457,7 @@ async fn persist_uploaded_file<'a>( target_path.display() ) })?; + set_file_mode(&target_path, mode.as_deref()).await?; // remove the WEB_ROOT prefix from the path, but keep the leading slash let path = "/".to_string() + target_path @@ -475,6 +477,26 @@ async fn protocol(request: &RequestInfo) -> &str { &request.protocol } +#[cfg(unix)] +async fn set_file_mode(path: &std::path::Path, mode: Option<&str>) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mode = if let Some(mode) = mode { + u32::from_str_radix(mode, 8) + .with_context(|| format!("unable to parse file mode {mode:?} as an octal number"))? + } else { + 0o600 + }; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .await + .with_context(|| format!("unable to set permissions on {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +async fn set_file_mode(_path: &std::path::Path, _mode: Option<&str>) -> anyhow::Result<()> { + Ok(()) +} + /// Returns a random string of the specified length. pub(crate) async fn random_string(len: usize) -> anyhow::Result { // OsRng can block on Linux, so we run this on a blocking thread. diff --git a/tests/uploads/mod.rs b/tests/uploads/mod.rs index 0e997676..7163235d 100644 --- a/tests/uploads/mod.rs +++ b/tests/uploads/mod.rs @@ -28,6 +28,62 @@ async fn test_file_upload(target: &str) -> actix_web::Result<()> { Ok(()) } +#[actix_web::test] +async fn test_persist_uploaded_file_mode() -> actix_web::Result<()> { + let app_data = crate::common::make_app_data().await; + let req = actix_web::test::TestRequest::get() + .uri("/tests/uploads/persist_with_mode.sql?mode=644") + .app_data(app_data.clone()) + .app_data(sqlpage::webserver::http::payload_config(&app_data)) + .app_data(sqlpage::webserver::http::form_config(&app_data)) + .insert_header((actix_web::http::header::ACCEPT, "application/json")) + .insert_header(("content-type", "multipart/form-data; boundary=1234567890")) + .set_payload( + "--1234567890\r\n\ + Content-Disposition: form-data; name=\"my_file\"; filename=\"test.txt\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + Hello\r\n\ + --1234567890--\r\n", + ) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::OK); + let body_json: serde_json::Value = test::read_body_json(resp).await; + let persisted_path = body_json[0]["contents"] + .as_str() + .expect("Path not found in JSON response"); + + // The path is relative to web root, we need to find it on disk. + // In tests, web root is the repo root. + // We normalize the path separators to be platform-specific for the file system check. + let normalized_path = persisted_path + .replace('\\', "/") + .trim_start_matches('/') + .replace('/', std::path::MAIN_SEPARATOR_STR); + let file_path = std::path::Path::new(&normalized_path); + assert!( + file_path.exists(), + "Persisted file {} does not exist. Persisted path: {}, JSON: {}", + file_path.display(), + persisted_path, + body_json + ); + let contents = std::fs::read_to_string(file_path)?; + assert_eq!(contents, "Hello"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(file_path)?; + assert_eq!(metadata.permissions().mode() & 0o777, 0o644); + } + + std::fs::remove_file(file_path)?; + Ok(()) +} + #[actix_web::test] async fn test_file_upload_direct() -> actix_web::Result<()> { test_file_upload("/tests/uploads/upload_file_test.sql").await diff --git a/tests/uploads/persist_with_mode.sql b/tests/uploads/persist_with_mode.sql new file mode 100644 index 00000000..4758edf0 --- /dev/null +++ b/tests/uploads/persist_with_mode.sql @@ -0,0 +1,2 @@ +set contents = sqlpage.persist_uploaded_file('my_file', 'tests_uploads', 'txt', $mode); +select 'text' as component, $contents as contents;