Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ xbed.sql
**/sqlpage.bin
node_modules/
sqlpage/sqlpage.db
tests_uploads/
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
24 changes: 23 additions & 1 deletion src/webserver/database/sqlpage_functions/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ super::function_definition_macro::sqlpage_functions! {
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);

path((&RequestInfo));
persist_uploaded_file((&RequestInfo), field_name: Cow<str>, folder: Option<Cow<str>>, allowed_extensions: Option<Cow<str>>);
persist_uploaded_file((&RequestInfo), field_name: Cow<str>, folder: Option<Cow<str>>, allowed_extensions: Option<Cow<str>>, mode: Option<Cow<str>>);
protocol((&RequestInfo));

random_string(string_length: SqlPageFunctionParam<usize>);
Expand Down Expand Up @@ -420,6 +420,7 @@ async fn persist_uploaded_file<'a>(
field_name: Cow<'a, str>,
folder: Option<Cow<'a, str>>,
allowed_extensions: Option<Cow<'a, str>>,
mode: Option<Cow<'a, str>>,
) -> anyhow::Result<Option<String>> {
let folder = folder.unwrap_or(Cow::Borrowed("uploads"));
let allowed_extensions_str =
Expand Down Expand Up @@ -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
Expand All @@ -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<String> {
// OsRng can block on Linux, so we run this on a blocking thread.
Expand Down
56 changes: 56 additions & 0 deletions tests/uploads/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/uploads/persist_with_mode.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
set contents = sqlpage.persist_uploaded_file('my_file', 'tests_uploads', 'txt', $mode);
select 'text' as component, $contents as contents;
Loading