From 9dd4bb317afb5b1034c82b1adc5b4683a665adda Mon Sep 17 00:00:00 2001 From: Martyn Date: Sat, 26 Sep 2026 09:21:53 +0100 Subject: [PATCH] Fixes default encoding for multipart files AI-Tool: Claude Code (Opus 5) --- CHANGELOG.md | 13 +++- core/lib/src/form/field.rs | 14 +++-- core/lib/src/form/from_form.rs | 6 +- core/lib/src/form/parser.rs | 14 ++++- core/lib/src/fs/temp_file.rs | 5 +- .../multipart-no-content-type-issue-2934.rs | 62 +++++++++++++++++++ 6 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 core/lib/tests/multipart-no-content-type-issue-2934.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf5e5118..18ec00426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 1.2.0 - 2026-07-29 +## Unreleased ### Added - Built-in WebSocket support behind rkt's optional `ws` feature, available as `rkt::ws`. The `rkt_ws` crate remains a deprecated compatibility shim for one full minor release cycle. + +### Fixed + +- Multipart file parts sent without a `Content-Type` are no longer corrupted by + UTF-8 decoding. A part with a `filename` is now treated as data, defaulting to + `application/octet-stream`. + +## 1.2.0 - 2026-07-29 + +### Added + - Support for the [PROXY protocol] (v1 and v2) behind the new `proxy-proto` crate feature. Setting the `proxy_protocol` configuration option (env `ROCKET_PROXY_PROTOCOL`) to `true` requires every connection to begin with a diff --git a/core/lib/src/form/field.rs b/core/lib/src/form/field.rs index 9eb078c20..13187e33f 100644 --- a/core/lib/src/form/field.rs +++ b/core/lib/src/form/field.rs @@ -9,8 +9,8 @@ use crate::{Data, Request}; /// A form field with a string value. /// /// rkt preprocesses all form fields into either [`ValueField`]s or -/// [`DataField`]s. All fields from url-encoded forms, and fields without -/// Content-Types from multipart forms, are preprocessed as a `ValueField`. +/// [`DataField`]s. All fields from url-encoded forms, and multipart fields with +/// neither a `Content-Type` nor a file name, are preprocessed as a `ValueField`. #[derive(Debug, Clone)] pub struct ValueField<'r> { /// The (decoded) name of the form field. @@ -22,16 +22,18 @@ pub struct ValueField<'r> { /// A multipart form field with an underlying data stream. /// /// rkt preprocesses all form fields into either [`ValueField`]s or -/// [`DataField`]s. Multipart form fields with a `Content-Type` are preprocessed -/// as a `DataField`. The underlying data is _not_ read into memory, but -/// instead, streamable from the contained [`Data`] structure. +/// [`DataField`]s. Multipart form fields with a `Content-Type`, or with a file +/// name in their `Content-Disposition`, are preprocessed as a `DataField`. The +/// underlying data is _not_ read into memory, but instead, streamable from the +/// contained [`Data`] structure. pub struct DataField<'r, 'i> { /// The (decoded) name of the form field. pub name: NameView<'r>, /// The form fields's file name. pub file_name: Option<&'r FileName>, /// The form field's Content-Type, as submitted, which may or may not - /// reflect on `data`. + /// reflect on `data`. A file field that submitted no `Content-Type` is + /// reported as [`ContentType::Binary`], the default for a file part. pub content_type: ContentType, /// The request in which the form field was submitted. pub request: &'r Request<'i>, diff --git a/core/lib/src/form/from_form.rs b/core/lib/src/form/from_form.rs index 58c539aed..ee2a412cc 100644 --- a/core/lib/src/form/from_form.rs +++ b/core/lib/src/form/from_form.rs @@ -206,9 +206,9 @@ use crate::http::uncased::AsUncased; /// are then pushed to the parser in [`FromForm::push_value()`] or /// [`FromForm::push_data()`], respectively. Both url-encoded forms and /// multipart forms are supported. All url-encoded form fields are preprocessed -/// as [`ValueField`]s. Multipart form fields with Content-Types are processed -/// as [`DataField`]s while those without a set Content-Type are processed as -/// [`ValueField`]s. `ValueField` field names and values are percent-decoded. +/// as [`ValueField`]s. Multipart form fields with a Content-Type, or with a file +/// name, are processed as [`DataField`]s while those with neither are processed +/// as [`ValueField`]s. `ValueField` field names and values are percent-decoded. /// /// [field wire format]: crate::form#field-wire-format /// diff --git a/core/lib/src/form/parser.rs b/core/lib/src/form/parser.rs index b34b8b667..b8e31d73b 100644 --- a/core/lib/src/form/parser.rs +++ b/core/lib/src/form/parser.rs @@ -3,7 +3,7 @@ use multer::Multipart; use crate::data::{Data, Limits, Outcome}; use crate::form::prelude::*; -use crate::http::{RawStr, Status}; +use crate::http::{ContentType, RawStr, Status}; use crate::request::{local_cache_once, Request}; type Result<'r, T> = std::result::Result>; @@ -148,9 +148,19 @@ impl<'r, 'i> MultipartParser<'r, 'i> { } }; - // A field with a content-type is data; one without is "value". + // A field with a content-type or a file name is data; one without is + // "value". RFC 7578 §4.4 defaults a part to `text/plain`, which would + // lossily decode a file's bytes as text, so a file part that declared no + // content-type falls back to binary here — the type that section + // recommends for file data that isn't otherwise labeled. trace!(?field, "multipart field"); let content_type = field.content_type().and_then(|m| m.as_ref().parse().ok()); + let content_type = match (content_type, field.file_name()) { + (Some(content_type), _) => Some(content_type), + (None, Some(_)) => Some(ContentType::Binary), + (None, None) => None, + }; + let field = if let Some(content_type) = content_type { let (name, file_name) = match (field.name(), field.file_name()) { (None, None) => ("", None), diff --git a/core/lib/src/fs/temp_file.rs b/core/lib/src/fs/temp_file.rs index b21b23fac..7828c8def 100644 --- a/core/lib/src/fs/temp_file.rs +++ b/core/lib/src/fs/temp_file.rs @@ -497,7 +497,10 @@ impl<'v> TempFile<'v> { /// /// A multipart data form field can optionally specify the content-type of a /// file. A browser will typically sniff the file's extension to set the - /// content-type. This method returns that value, if it was specified. + /// content-type. This method returns that value, if it was specified. A file + /// field that specified none reports [`ContentType::Binary`], the default + /// for a file part, so an omitted header is indistinguishable from an + /// explicit `application/octet-stream`. /// /// ```rust /// #[macro_use] extern crate rkt; diff --git a/core/lib/tests/multipart-no-content-type-issue-2934.rs b/core/lib/tests/multipart-no-content-type-issue-2934.rs new file mode 100644 index 000000000..abd66dc97 --- /dev/null +++ b/core/lib/tests/multipart-no-content-type-issue-2934.rs @@ -0,0 +1,62 @@ +#[macro_use] +extern crate rkt; + +use rkt::form::Form; +use rkt::fs::TempFile; +use rkt::http::ContentType; +use rkt::tokio::io::AsyncReadExt; + +#[derive(FromForm)] +struct Upload<'r> { + file: TempFile<'r>, + note: &'r str, +} + +#[rkt::post("/", data = "
")] +async fn upload(form: Form>) -> Vec { + let mut bytes = form.note.as_bytes().to_vec(); + bytes.push(b'|'); + + // A file part that declared no `Content-Type` defaults to binary. + let content_type = form.file.content_type().map(|ct| ct.to_string()); + bytes.extend(content_type.unwrap_or_else(|| "none".into()).as_bytes()); + bytes.push(b'|'); + + // `open()` reads buffered and on-disk uploads alike. + let mut stream = form.file.open().await.unwrap(); + stream.read_to_end(&mut bytes).await.unwrap(); + bytes +} + +/// A file part with a `filename` but no `Content-Type` must be delivered +/// byte-for-byte, not lossily decoded as UTF-8. See issue #2934. +#[test] +fn file_part_without_content_type_preserves_bytes() { + use rkt::local::blocking::Client; + + let payload: Vec = (0u8..=255).collect(); + + let mut body = Vec::new(); + body.extend(b"--X-BOUNDARY\r\n"); + body.extend(b"Content-Disposition: form-data; name=\"note\"\r\n\r\n"); + body.extend(b"hello\r\n"); + body.extend(b"--X-BOUNDARY\r\n"); + body.extend(b"Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n\r\n"); + body.extend(&payload); + body.extend(b"\r\n--X-BOUNDARY--\r\n"); + + let client = Client::debug_with(routes![upload]).unwrap(); + let response = client + .post("/") + .header( + "multipart/form-data; boundary=X-BOUNDARY" + .parse::() + .unwrap(), + ) + .body(body) + .dispatch(); + + let mut expected = b"hello|application/octet-stream|".to_vec(); + expected.extend(&payload); + assert_eq!(response.into_bytes().unwrap(), expected); +}