Skip to main content

max / makenotwork

audit Run 14 Phase 0: reject line breaks in single-line titles, dedup instrument, align archive verdict - validation: reject control chars (CR/LF/tab/etc) in item/chapter/blog/collection/ section/project/issue/link titles via shared reject_control_chars; a newline title would make Postmark reject the sale-notification email. Multi-line fields unaffected. - embed/item.rs: drop duplicate #[tracing::instrument] on item_player. - scanning/archive.rs: unsupported-container reject returns LayerVerdict::Error (matches the FailClosed policy) instead of the misleading Skip. - tests: newline cases for item/chapter/blog/collection/section/project titles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 22:32 UTC
Commit: d858d8b0f52507e9b3d075e8d6235a22b92b46ee
Parent: 21e12d0
6 files changed, +49 insertions, -2 deletions
@@ -155,7 +155,6 @@ pub(super) async fn item_card(
155 155
156 156 // ─── Audio Player ───────────────────────────────────────────────────────────
157 157
158 - #[tracing::instrument(skip_all, name = "embed::item_player")]
159 158 /// GET /embed/i/{item_id}/player
160 159 ///
161 160 /// Audio preview player embed. Returns 404 for non-audio items.
@@ -221,7 +221,10 @@ pub fn inspect_archive(
221 221 let detail = format!(
222 222 "{container} archives are not accepted for this upload type (cannot be decompression-bomb inspected)"
223 223 );
224 - return (error(detail.clone()), nested(LayerVerdict::Skip, detail));
224 + // Outer layer is FailClosed, so returning Error (not Skip) aligns the
225 + // nested verdict with the policy: the file was rejected before interior
226 + // scanning, not merely left unscanned.
227 + return (error(detail.clone()), nested(LayerVerdict::Error, detail));
225 228 }
226 229
227 230 // A cover-disguised archive: layer 1 (content_type) handles the type
@@ -14,6 +14,7 @@ pub fn validate_item_title(title: &str) -> Result<(), AppError> {
14 14 limits::ITEM_TITLE_MAX
15 15 )));
16 16 }
17 + super::reject_control_chars("Title", title)?;
17 18 Ok(())
18 19 }
19 20
@@ -39,6 +40,7 @@ pub fn validate_chapter_title(title: &str) -> Result<(), AppError> {
39 40 limits::CHAPTER_TITLE_MAX
40 41 )));
41 42 }
43 + super::reject_control_chars("Chapter title", title)?;
42 44 Ok(())
43 45 }
44 46
@@ -145,6 +147,7 @@ pub fn validate_blog_post_title(title: &str) -> Result<(), AppError> {
145 147 limits::BLOG_POST_TITLE_MAX
146 148 )));
147 149 }
150 + super::reject_control_chars("Blog post title", title)?;
148 151 Ok(())
149 152 }
150 153
@@ -201,6 +204,7 @@ pub fn validate_collection_title(title: &str) -> Result<(), AppError> {
201 204 limits::COLLECTION_TITLE_MAX
202 205 )));
203 206 }
207 + super::reject_control_chars("Collection title", title)?;
204 208 Ok(())
205 209 }
206 210
@@ -227,6 +231,7 @@ pub fn validate_section_title(title: &str) -> Result<(), AppError> {
227 231 limits::SECTION_TITLE_MAX
228 232 )));
229 233 }
234 + super::reject_control_chars("Section title", trimmed)?;
230 235 Ok(())
231 236 }
232 237
@@ -254,6 +259,11 @@ mod tests {
254 259 assert!(validate_item_title("My Song").is_ok());
255 260 assert!(validate_item_title("").is_err()); // empty
256 261 assert!(validate_item_title(&"a".repeat(201)).is_err()); // too long
262 + // Single-line: no CR/LF/tab/control chars (would break email subjects).
263 + assert!(validate_item_title("Song\r\nBcc: attacker@evil.com").is_err());
264 + assert!(validate_item_title("Song\nInjection").is_err());
265 + assert!(validate_item_title("Song\ttab").is_err());
266 + assert!(validate_item_title("Song\0null").is_err());
257 267 }
258 268
259 269 #[test]
@@ -270,6 +280,7 @@ mod tests {
270 280 assert!(validate_chapter_title("").is_err()); // empty
271 281 assert!(validate_chapter_title(&"a".repeat(200)).is_ok()); // at limit
272 282 assert!(validate_chapter_title(&"a".repeat(201)).is_err()); // over limit
283 + assert!(validate_chapter_title("Intro\r\nX").is_err()); // no line breaks
273 284 }
274 285
275 286 #[test]
@@ -318,6 +329,19 @@ mod tests {
318 329 assert!(validate_blog_post_title("").is_err()); // empty
319 330 assert!(validate_blog_post_title(&"a".repeat(200)).is_ok()); // at limit
320 331 assert!(validate_blog_post_title(&"a".repeat(201)).is_err()); // over limit
332 + assert!(validate_blog_post_title("Post\r\nSubject-Injection").is_err());
333 + }
334 +
335 + #[test]
336 + fn test_validate_collection_title_rejects_line_breaks() {
337 + assert!(validate_collection_title("My Collection").is_ok());
338 + assert!(validate_collection_title("Coll\r\nX").is_err());
339 + }
340 +
341 + #[test]
342 + fn test_validate_section_title_rejects_line_breaks() {
343 + assert!(validate_section_title("Overview").is_ok());
344 + assert!(validate_section_title("Sec\ntion").is_err());
321 345 }
322 346
323 347 #[test]
@@ -14,6 +14,23 @@ pub use users::*;
14 14
15 15 use crate::error::AppError;
16 16
17 + /// Reject control characters in a single-line field.
18 + ///
19 + /// Single-line titles/labels (item, chapter, blog-post, collection, section,
20 + /// project, issue, link) must not contain newlines, carriage returns, tabs, or
21 + /// other control characters. Besides being malformed, a `\r`/`\n` in a value
22 + /// that reaches an email subject makes Postmark reject the send (the JSON API
23 + /// refuses CRLF in headers). Multi-line fields (bio, description, body) use
24 + /// their own looser validators that permit `\n`/`\r`/`\t`.
25 + pub fn reject_control_chars(field: &str, value: &str) -> Result<(), AppError> {
26 + if value.chars().any(|c| c.is_control()) {
27 + return Err(AppError::validation(format!(
28 + "{field} cannot contain line breaks or control characters"
29 + )));
30 + }
31 + Ok(())
32 + }
33 +
17 34 /// Maximum lengths for various fields
18 35 pub mod limits {
19 36 pub const DISPLAY_NAME_MAX: usize = 100;
@@ -14,6 +14,7 @@ pub fn validate_project_title(title: &str) -> Result<(), AppError> {
14 14 limits::PROJECT_TITLE_MAX
15 15 )));
16 16 }
17 + super::reject_control_chars("Project title", title)?;
17 18 Ok(())
18 19 }
19 20
@@ -44,6 +45,7 @@ pub fn validate_issue_title(title: &str) -> Result<(), AppError> {
44 45 limits::ISSUE_TITLE_MAX
45 46 )));
46 47 }
48 + super::reject_control_chars("Issue title", title)?;
47 49 Ok(())
48 50 }
49 51
@@ -167,6 +169,7 @@ mod tests {
167 169 assert!(validate_project_title("My Project").is_ok());
168 170 assert!(validate_project_title("").is_err()); // empty
169 171 assert!(validate_project_title(&"a".repeat(201)).is_err()); // too long
172 + assert!(validate_project_title("Proj\r\nInjection").is_err()); // no line breaks
170 173 }
171 174
172 175 #[test]
@@ -110,6 +110,7 @@ pub fn validate_link_title(title: &str) -> Result<(), AppError> {
110 110 "Link title cannot contain zero-width or bidirectional characters".to_string(),
111 111 ));
112 112 }
113 + super::reject_control_chars("Link title", title)?;
113 114 Ok(())
114 115 }
115 116