Skip to main content

tuwunel_core/utils/
content_disposition.rs

1//! HTTP content-disposition selection and filename sanitization.
2//!
3//! Media types are checked against the safe-inline list before selecting a
4//! disposition. Optional filenames are sanitized before header construction.
5
6use ruma::http_headers::{ContentDisposition, ContentDispositionType};
7
8use crate::debug_info;
9
10/// as defined by MSC2702
11const ALLOWED_INLINE_CONTENT_TYPES: [&str; 26] = [
12	// keep sorted
13	"application/json",
14	"application/ld+json",
15	"audio/aac",
16	"audio/flac",
17	"audio/mp4",
18	"audio/mpeg",
19	"audio/ogg",
20	"audio/wav",
21	"audio/wave",
22	"audio/webm",
23	"audio/x-flac",
24	"audio/x-pn-wav",
25	"audio/x-wav",
26	"image/apng",
27	"image/avif",
28	"image/gif",
29	"image/jpeg",
30	"image/png",
31	"image/webp",
32	"text/css",
33	"text/csv",
34	"text/plain",
35	"video/mp4",
36	"video/ogg",
37	"video/quicktime",
38	"video/webm",
39];
40
41/// Returns a Content-Disposition of `attachment` or `inline`, depending on the
42/// Content-Type against MSC2702 list of safe inline Content-Types
43/// (`ALLOWED_INLINE_CONTENT_TYPES`)
44#[must_use]
45pub fn content_disposition_type(content_type: Option<&str>) -> ContentDispositionType {
46	let Some(content_type) = content_type else {
47		debug_info!("No Content-Type was given, assuming attachment for Content-Disposition");
48		return ContentDispositionType::Attachment;
49	};
50
51	debug_assert!(
52		ALLOWED_INLINE_CONTENT_TYPES.is_sorted(),
53		"ALLOWED_INLINE_CONTENT_TYPES is not sorted"
54	);
55
56	let essence = content_type_essence(content_type);
57
58	// The list is lowercase and ordered bytewise, so folding the needle's case as
59	// it is compared searches the same order without copying it.
60	let allowed = ALLOWED_INLINE_CONTENT_TYPES
61		.binary_search_by(|allowed| {
62			allowed.bytes().cmp(
63				essence
64					.bytes()
65					.map(|byte| byte.to_ascii_lowercase()),
66			)
67		})
68		.is_ok();
69
70	if allowed {
71		ContentDispositionType::Inline
72	} else {
73		ContentDispositionType::Attachment
74	}
75}
76
77/// Whether a Content-Type names the given media type.
78///
79/// Media types are case-insensitive per RFC 9110 section 8.3.1, so the
80/// comparison folds case rather than requiring an exact spelling.
81#[inline]
82#[must_use]
83pub fn content_type_is(content_type: Option<&str>, essence: &str) -> bool {
84	content_type.is_some_and(|content_type| {
85		content_type_essence(content_type).eq_ignore_ascii_case(essence)
86	})
87}
88
89/// The media type of a Content-Type, without its parameters.
90///
91/// A header value is `type/subtype` followed by optional `;` parameters, and
92/// callers deciding what a body is must weigh only the former: a parameter that
93/// merely contains a media type does not make the body that type.
94#[inline]
95#[must_use]
96pub fn content_type_essence(content_type: &str) -> &str {
97	content_type
98		.split(';')
99		.next()
100		.unwrap_or(content_type)
101		.trim()
102}
103
104/// sanitises the file name for the Content-Disposition using
105/// `sanitize_filename` crate
106#[tracing::instrument(level = "debug")]
107pub fn sanitise_filename(filename: &str) -> String {
108	sanitize_filename::sanitize_with_options(filename, sanitize_filename::Options {
109		truncate: false,
110		..Default::default()
111	})
112}
113
114/// creates the final Content-Disposition based on whether the filename exists
115/// or not, or if a requested filename was specified (media download with
116/// filename)
117///
118/// if filename exists:
119/// `Content-Disposition: attachment/inline; filename=filename.ext`
120///
121/// else: `Content-Disposition: attachment/inline`
122pub fn make_content_disposition(
123	content_disposition: Option<&ContentDisposition>,
124	content_type: Option<&str>,
125	filename: Option<&str>,
126) -> ContentDisposition {
127	ContentDisposition::new(content_disposition_type(content_type)).with_filename(
128		filename
129			.or_else(|| {
130				content_disposition
131					.and_then(|content_disposition| content_disposition.filename.as_deref())
132			})
133			.map(sanitise_filename),
134	)
135}
136
137#[cfg(test)]
138mod tests {
139	#[test]
140	fn string_sanitisation() {
141		const SAMPLE: &str = "🏳️‍⚧️this\\r\\n įs \r\\n ä \\r\nstrïng 🥴that\n\r \
142		                      ../../../../../../../may be\r\n malicious🏳️‍⚧️";
143		const SANITISED: &str = "🏳️‍⚧️thisrn įs n ä rstrïng 🥴that ..............may be malicious🏳️‍⚧️";
144
145		let options = sanitize_filename::Options {
146			windows: true,
147			truncate: true,
148			replacement: "",
149		};
150
151		// cargo test -- --nocapture
152		println!("{SAMPLE}");
153		println!("{}", sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
154		println!("{SAMPLE:?}");
155		println!("{:?}", sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
156
157		assert_eq!(SANITISED, sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
158	}
159
160	#[test]
161	fn empty_sanitisation() {
162		use crate::utils::string::EMPTY;
163
164		let result =
165			sanitize_filename::sanitize_with_options(EMPTY, sanitize_filename::Options {
166				windows: true,
167				truncate: true,
168				replacement: "",
169			});
170
171		assert_eq!(EMPTY, result);
172	}
173
174	#[test]
175	fn content_type_essence_drops_parameters() {
176		use super::content_type_essence;
177
178		assert_eq!(content_type_essence("text/html; charset=utf-8"), "text/html");
179		assert_eq!(content_type_essence(" text/html "), "text/html");
180		assert_eq!(content_type_essence("text/html"), "text/html");
181	}
182
183	#[test]
184	fn content_type_is_matches_any_case() {
185		use super::content_type_is;
186
187		for content_type in ["text/html", "Text/HTML", "TEXT/HTML", "text/HTML; charset=utf-8"] {
188			assert!(content_type_is(Some(content_type), "text/html"), "{content_type} is html");
189		}
190	}
191
192	#[test]
193	fn content_type_is_rejects_other_types() {
194		use super::content_type_is;
195
196		for content_type in
197			["application/json; x=text/html", "text/plain", "application/xhtml+xml"]
198		{
199			assert!(
200				!content_type_is(Some(content_type), "text/html"),
201				"{content_type} is not html"
202			);
203		}
204
205		assert!(!content_type_is(None, "text/html"), "an absent content type is not html");
206	}
207
208	#[test]
209	fn inline_disposition_ignores_case_and_parameters() {
210		use ruma::http_headers::ContentDispositionType;
211
212		use super::content_disposition_type;
213
214		for content_type in
215			["image/png", "IMAGE/PNG", "Image/Png; charset=binary", " text/plain "]
216		{
217			assert!(
218				matches!(
219					content_disposition_type(Some(content_type)),
220					ContentDispositionType::Inline
221				),
222				"{content_type} is safe to inline"
223			);
224		}
225	}
226
227	#[test]
228	fn everything_else_is_an_attachment() {
229		use ruma::http_headers::ContentDispositionType;
230
231		use super::content_disposition_type;
232
233		for content_type in [Some("text/html"), Some("application/octet-stream"), Some(""), None]
234		{
235			assert!(
236				matches!(
237					content_disposition_type(content_type),
238					ContentDispositionType::Attachment
239				),
240				"{content_type:?} is not safe to inline"
241			);
242		}
243	}
244}