Skip to content

Commit 04d732b

Browse files
authored
Finish multipart production hardening
1 parent 46afb7f commit 04d732b

23 files changed

Lines changed: 731 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ jobs:
8383
cargo test -p fastmulp-core --doc --locked
8484
cargo test -p fastmulp-wasm --doc --locked
8585
86+
bindings:
87+
runs-on: ubuntu-latest
88+
timeout-minutes: 15
89+
steps:
90+
- uses: actions/checkout@v6
91+
- uses: dtolnay/rust-toolchain@stable
92+
with:
93+
targets: wasm32-unknown-unknown
94+
- uses: Swatinem/rust-cache@v2
95+
- uses: actions/setup-node@v6
96+
with:
97+
node-version: "24"
98+
- name: Enable pnpm
99+
run: corepack enable
100+
- name: Install wasm-bindgen CLI
101+
run: bash scripts/install-wasm-bindgen.sh
102+
- name: Node binding smoke test
103+
run: pnpm test:node-binding
104+
- name: Wasm binding smoke test
105+
run: pnpm test:wasm-binding
106+
86107
bench:
87108
if: github.event_name == 'workflow_dispatch'
88109
runs-on: ubuntu-latest
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: release-bindings
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: false
14+
15+
defaults:
16+
run:
17+
shell: bash
18+
19+
env:
20+
CARGO_TERM_COLOR: always
21+
22+
jobs:
23+
publish-binding-artifacts:
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 20
26+
steps:
27+
- uses: actions/checkout@v6
28+
with:
29+
fetch-depth: 0
30+
- uses: dtolnay/rust-toolchain@stable
31+
with:
32+
targets: wasm32-unknown-unknown
33+
- uses: Swatinem/rust-cache@v2
34+
- uses: actions/setup-node@v6
35+
with:
36+
node-version: "24"
37+
- name: Enable pnpm
38+
run: corepack enable
39+
- name: Verify tag is on main
40+
run: git branch --remote --contains "${GITHUB_SHA}" | grep -qx ' origin/main'
41+
- name: Verify tag matches workspace version
42+
run: |
43+
expected="${GITHUB_REF_NAME#v}"
44+
actual="$(awk '
45+
/^\[workspace\.package\]/ { in_section=1; next }
46+
/^\[/ && in_section { exit }
47+
in_section && /^version = "/ {
48+
gsub(/^version = "/, "", $0)
49+
gsub(/"$/, "", $0)
50+
print
51+
exit
52+
}
53+
' Cargo.toml)"
54+
test "${expected}" = "${actual}"
55+
- name: Build and test Node binding
56+
run: pnpm test:node-binding
57+
- name: Install wasm-bindgen CLI
58+
run: bash scripts/install-wasm-bindgen.sh
59+
- name: Test wasm binding
60+
run: pnpm test:wasm-binding
61+
- name: Package binding artifacts
62+
run: node scripts/package-bindings.mjs --pack
63+
- name: Checksum artifacts
64+
run: |
65+
cd dist/bindings/artifacts
66+
sha256sum *.tgz > SHA256SUMS
67+
- name: Create or update GitHub release
68+
env:
69+
GH_TOKEN: ${{ github.token }}
70+
run: |
71+
gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 \
72+
|| gh release create "${GITHUB_REF_NAME}" --verify-tag --title "${GITHUB_REF_NAME}"
73+
gh release upload "${GITHUB_REF_NAME}" dist/bindings/artifacts/*.tgz dist/bindings/artifacts/SHA256SUMS --clobber

README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ High-accuracy, low-allocation `multipart/form-data` parsing with a zero-copy Rus
1313
- The Rust core parses against a borrowed `&[u8]` and returns body ranges instead of copying payload bytes.
1414
- `Content-Disposition` is parsed eagerly, and `name` is enforced for `form-data` parts.
1515
- Header storage uses `SmallVec` so the common case stays stack-friendly.
16-
- Node.js and browser bindings return metadata plus `body_start` / `body_end`, so callers can slice the original buffer themselves.
16+
- Node.js and browser bindings return metadata plus body ranges, so callers can slice the original buffer themselves.
1717
- Boundary lines accept RFC 2046 transport padding, plus MIME-style preamble and epilogue.
1818

1919
## Spec Notes
@@ -51,15 +51,14 @@ Node.js:
5151
import { parse } from "./fastmulp.node";
5252

5353
const parts = parse(bodyBuffer, boundary);
54-
const fileBytes = bodyBuffer.subarray(parts[0].body_start, parts[0].body_end);
54+
const fileBytes = bodyBuffer.subarray(parts[0].bodyStart, parts[0].bodyEnd);
5555
```
5656

5757
Browser:
5858

5959
```ts
60-
import init, { parse } from "./fastmulp_wasm.js";
60+
import { parse } from "fastmulp-wasm";
6161

62-
await init();
6362
const parts = parse(formBytes, boundary);
6463
const fieldBytes = formBytes.subarray(parts[0].body_start, parts[0].body_end);
6564
```
@@ -68,14 +67,36 @@ The wasm target still needs one JS-to-wasm copy at the ABI boundary, but it avoi
6867

6968
Older nested `multipart/mixed` payloads can be handled by recursively calling `parse` on a part body after extracting the nested boundary from that part's `Content-Type`.
7069

70+
### Parser Limits
71+
72+
Use `parse_with_limits` or `MultipartParser::new_with_limits` when parsing untrusted uploads on a server boundary. `ParseLimits` can cap the number of parts, the number of headers per part, and the number of header bytes per part. The plain `parse` API stays unlimited for compatibility and small trusted payloads.
73+
74+
### Security Notes
75+
76+
Treat `filename` as display metadata only. Strip path separators and platform-specific path components before using it in storage, generate your own server-side object names, and keep the original value only as untrusted metadata. Preserve duplicate field names in order unless your application explicitly defines a merge rule. For nested `multipart/mixed`, extract the nested boundary from that part's `Content-Type` and parse the nested body with its own limits.
77+
78+
### Binding Artifacts
79+
80+
Tag pushes build npm-compatible release artifacts for the JS targets:
81+
82+
- `fastmulp-node-linux-x64-*.tgz`: platform-specific Node.js native addon containing `fastmulp.node`, CommonJS entrypoint, and TypeScript declarations.
83+
- `fastmulp-wasm-*.tgz`: browser wasm package containing wasm-bindgen generated JavaScript glue, wasm, and TypeScript declarations.
84+
85+
The Node binding uses camelCase object fields such as `bodyStart`, `bodyEnd`, `fileName`, and `contentType`. The wasm binding preserves the existing snake_case field names generated by `wasm-bindgen`.
86+
87+
```ts
88+
import { parse as parseNode } from "fastmulp-node-linux-x64";
89+
import { parse as parseWasm } from "fastmulp-wasm";
90+
```
91+
7192
## Release
7293

7394
- `vp run release:patch`
7495
- `vp run release:minor`
7596
- `vp run release:alpha`
7697
- `vp run release:beta`
7798

78-
Each release command updates the workspace version in `Cargo.toml`, creates a release commit, and creates the matching `v...` git tag. Tag pushes publish `fastmulp-core` through GitHub Actions trusted publishing.
99+
Each release command updates the workspace version in `Cargo.toml`, creates a release commit, and creates the matching `v...` git tag. Tag pushes publish `fastmulp-core` through GitHub Actions trusted publishing and attach Node/wasm binding artifacts to the matching GitHub Release.
79100

80101
## Shared Tasks
81102

crates/fastmulp_core/src/boundary.rs

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::borrow::Cow;
2+
13
use smallvec::SmallVec;
24

35
use crate::{
@@ -11,8 +13,12 @@ pub struct Boundary<'a> {
1113
opening: SmallVec<[u8; 72]>,
1214
}
1315

14-
pub fn boundary_from_content_type(_content_type: &str) -> Option<&str> {
15-
let bytes = _content_type.as_bytes();
16+
/// Extracts the `boundary` parameter from a multipart `Content-Type` header value.
17+
///
18+
/// The returned value is borrowed for ordinary parameters and owned when a
19+
/// quoted-string contains MIME quoted-pair escapes that need to be decoded.
20+
pub fn boundary_from_content_type(content_type: &str) -> Option<Cow<'_, str>> {
21+
let bytes = content_type.as_bytes();
1622
let mut cursor = skip_ascii_whitespace(bytes, 0);
1723
let media_start = cursor;
1824

@@ -52,17 +58,13 @@ pub fn boundary_from_content_type(_content_type: &str) -> Option<&str> {
5258
cursor += 1;
5359
cursor = skip_ascii_whitespace(bytes, cursor);
5460

55-
let value = parse_content_type_value(_content_type, cursor)?;
61+
let value = parse_content_type_value(content_type, cursor)?;
5662
cursor = skip_ascii_whitespace(bytes, value.next);
5763
if cursor < bytes.len() && bytes[cursor] != b';' {
5864
return None;
5965
}
6066

6167
if eq_ignore_ascii_case(name, b"boundary") {
62-
if value.requires_unescape {
63-
return None;
64-
}
65-
6668
return Some(value.raw);
6769
}
6870
}
@@ -106,9 +108,8 @@ impl<'a> Boundary<'a> {
106108
}
107109

108110
struct ContentTypeValue<'a> {
109-
raw: &'a str,
111+
raw: Cow<'a, str>,
110112
next: usize,
111-
requires_unescape: bool,
112113
}
113114

114115
fn parse_content_type_value(content_type: &str, start: usize) -> Option<ContentTypeValue<'_>> {
@@ -120,26 +121,37 @@ fn parse_content_type_value(content_type: &str, start: usize) -> Option<ContentT
120121
if bytes[start] == b'"' {
121122
let inner_start = start + 1;
122123
let mut cursor = inner_start;
123-
let mut requires_unescape = false;
124+
let mut decoded = None::<Vec<u8>>;
125+
let mut copied_from = inner_start;
124126
while cursor < bytes.len() {
125127
match bytes[cursor] {
126128
b'"' => {
129+
if let Some(mut decoded) = decoded {
130+
decoded.extend_from_slice(&bytes[copied_from..cursor]);
131+
let raw = String::from_utf8(decoded).ok()?;
132+
return Some(ContentTypeValue {
133+
raw: Cow::Owned(raw),
134+
next: cursor + 1,
135+
});
136+
}
137+
127138
return content_type
128139
.get(inner_start..cursor)
129140
.map(|raw| ContentTypeValue {
130-
raw,
141+
raw: Cow::Borrowed(raw),
131142
next: cursor + 1,
132-
requires_unescape,
133143
});
134144
}
135145
b'\\' => {
136-
requires_unescape = true;
137-
cursor += 1;
138-
if cursor == bytes.len() {
146+
if cursor + 1 == bytes.len() {
139147
return None;
140148
}
141149

142-
cursor += 1;
150+
let decoded = decoded.get_or_insert_with(|| Vec::with_capacity(bytes.len()));
151+
decoded.extend_from_slice(&bytes[copied_from..cursor]);
152+
decoded.push(bytes[cursor + 1]);
153+
cursor += 2;
154+
copied_from = cursor;
143155
}
144156
_ => {
145157
cursor += 1;
@@ -157,9 +169,8 @@ fn parse_content_type_value(content_type: &str, start: usize) -> Option<ContentT
157169

158170
let end = trim_ascii_end(bytes, cursor);
159171
content_type.get(start..end).map(|raw| ContentTypeValue {
160-
raw,
172+
raw: Cow::Borrowed(raw),
161173
next: cursor,
162-
requires_unescape: false,
163174
})
164175
}
165176

crates/fastmulp_core/src/error.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use core::fmt;
22

3+
#[non_exhaustive]
34
#[derive(Debug, Clone, PartialEq, Eq)]
45
pub enum Error {
56
EmptyBoundary,
@@ -17,6 +18,9 @@ pub enum Error {
1718
InvalidContentDisposition { offset: usize },
1819
MissingClosingBoundary { offset: usize },
1920
TrailingData { offset: usize },
21+
PartLimitExceeded { limit: usize },
22+
HeaderCountLimitExceeded { limit: usize, offset: usize },
23+
HeaderBytesLimitExceeded { limit: usize, offset: usize },
2024
}
2125

2226
impl fmt::Display for Error {
@@ -87,6 +91,17 @@ impl fmt::Display for Error {
8791
f,
8892
"multipart body has trailing data after byte offset {offset}"
8993
),
94+
Self::PartLimitExceeded { limit } => {
95+
write!(f, "multipart part count exceeded configured limit {limit}")
96+
}
97+
Self::HeaderCountLimitExceeded { limit, offset } => write!(
98+
f,
99+
"multipart header count exceeded configured limit {limit} at byte offset {offset}"
100+
),
101+
Self::HeaderBytesLimitExceeded { limit, offset } => write!(
102+
f,
103+
"multipart header bytes exceeded configured limit {limit} at byte offset {offset}"
104+
),
90105
}
91106
}
92107
}

crates/fastmulp_core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ mod util;
2626
pub use boundary::{Boundary, boundary_from_content_type};
2727
pub use error::{Error, Result};
2828
pub use header::Header;
29-
pub use parser::{Multipart, MultipartParser, parse};
29+
pub use parser::{Multipart, MultipartParser, ParseLimits, parse, parse_with_limits};
3030
pub use part::Part;
3131
pub use text::TextValue;
3232

0 commit comments

Comments
 (0)