contextual_encoder/lib.rs
1#![forbid(unsafe_code)]
2
3//! contextual output encoding for XSS defense and safe literal embedding.
4//!
5//! this crate provides context-aware encoding functions inspired by the
6//! [OWASP Java Encoder](https://owasp.org/owasp-java-encoder/). each function
7//! encodes input for safe embedding in a specific output context — web contexts
8//! (HTML, XML, JavaScript, CSS, URI) and source literal contexts (Rust).
9//!
10//! **disclaimer:** contextual-encoder is an independent Rust crate. its API and security model
11//! are inspired by the OWASP Java Encoder, but this project is not affiliated with,
12//! endorsed by, or maintained by the OWASP Foundation.
13//!
14//! # quick start
15//!
16//! ```
17//! use contextual_encoder::{for_html, for_javascript, for_css_string, for_uri_component};
18//!
19//! let user_input = "<script>alert('xss')</script>";
20//!
21//! // safe for HTML text content and quoted attributes
22//! let html_safe = for_html(user_input);
23//! assert!(html_safe.contains("<script>"));
24//!
25//! // safe for javascript string literals (universal)
26//! let js_safe = for_javascript(user_input);
27//! assert!(js_safe.contains(r"\x3c\/script>"));
28//!
29//! // safe for quoted CSS string values
30//! let css_safe = for_css_string(user_input);
31//! assert!(css_safe.contains(r"\3c"));
32//!
33//! // safe as a URI query parameter value
34//! let uri_safe = for_uri_component(user_input);
35//! assert!(uri_safe.contains("%3C"));
36//! ```
37//!
38//! # available contexts
39//!
40//! ## HTML
41//!
42//! | function | safe for |
43//! |----------|----------|
44//! | [`for_html`] | text content + quoted attributes |
45//! | [`for_html_content`] | text content only |
46//! | [`for_html_attribute`] | quoted attributes only |
47//! | [`for_html_unquoted_attribute`] | unquoted attribute values |
48//!
49//! ## XML
50//!
51//! | function | safe for |
52//! |----------|----------|
53//! | [`for_xml`] | XML text content + quoted attributes (alias for `for_html`) |
54//! | [`for_xml_content`] | XML text content only (alias for `for_html_content`) |
55//! | [`for_xml_attribute`] | quoted XML attributes only (alias for `for_html_attribute`) |
56//! | [`for_xml_comment`] | XML comment content |
57//! | [`for_cdata`] | CDATA section content |
58//!
59//! ## XML 1.1
60//!
61//! | function | safe for |
62//! |----------|----------|
63//! | [`for_xml11`] | XML 1.1 content + quoted attributes |
64//! | [`for_xml11_content`] | XML 1.1 content only |
65//! | [`for_xml11_attribute`] | XML 1.1 quoted attributes only |
66//!
67//! ## JavaScript
68//!
69//! | function | safe for |
70//! |----------|----------|
71//! | [`for_javascript`] | general JS string contexts |
72//! | [`for_javascript_attribute`] | HTML event attributes |
73//! | [`for_javascript_block`] | `<script>` blocks |
74//! | [`for_javascript_source`] | standalone .js files |
75//! | [`for_js_template`] | ES6 template literal content (`` `...` ``) |
76//!
77//! ## CSS
78//!
79//! | function | safe for |
80//! |----------|----------|
81//! | [`for_css_string`] | quoted CSS string values |
82//! | [`for_css_url`] | CSS `url()` values, quoted or unquoted |
83//!
84//! ## URI
85//!
86//! | function | safe for |
87//! |----------|----------|
88//! | [`for_uri_component`] | URI components (query params, path segments) |
89//! | [`for_uri_path`] | URI paths (preserves `/` separators) |
90//! | [`for_form_urlencoded`] | `application/x-www-form-urlencoded` values |
91//!
92//! ## additional literal contexts
93//!
94//! these encoders are not part of the OWASP Java Encoder's scope. they encode
95//! untrusted strings for safe embedding in source code literals.
96//!
97//! | function | safe for |
98//! |----------|----------|
99//! | [`for_json`] | JSON string values |
100//! | [`for_rust_string`] | Rust string literals (`"..."`) |
101//! | [`for_rust_char`] | Rust char literals (`'...'`), input must be exactly one character |
102//! | [`for_rust_char_checked`] | Rust char literals, `None` unless the input is exactly one character |
103//! | [`for_rust_byte_string`] | Rust byte string literals (`b"..."`) |
104//! | [`for_sql`] | Standard SQL string literals (`'...'`) |
105//! | [`for_sql_backslash`] | MySQL/MariaDB string literals with backslash escaping (`'...'`) |
106//!
107//! # security model
108//!
109//! this is a **contextual output encoder**, not a sanitizer. it prevents
110//! cross-site scripting by encoding output for specific contexts, but it
111//! does not validate or sanitize input.
112//!
113//! **important caveats:**
114//!
115//! - **encoding is not sanitization.** encoding `<script>` as `<script>`
116//! makes it display safely in HTML, but does not remove it. if you need to
117//! allow a subset of HTML, use a dedicated sanitizer.
118//! - **context matters.** using the wrong encoder for a context can leave
119//! you vulnerable. `for_html_content` output is not safe in attributes.
120//! - **tag and attribute names cannot be encoded.** never pass untrusted data
121//! as a tag name, attribute name, or event handler name. validate these
122//! against a whitelist.
123//! - **SQL identifiers cannot be encoded.** [`for_sql`] and
124//! [`for_sql_backslash`] encode string literals (`'...'`). an identifier is
125//! delimited by `"..."` or `` `...` ``, and neither delimiter is escaped.
126//! validate an untrusted table or column name against a whitelist.
127//! - **full URLs must be validated separately.** `for_uri_component` encodes
128//! a component, not a full URL. to embed an untrusted URL, validate its
129//! scheme and structure first, then encode for the final sink.
130//! - **template literals.** the string literal JavaScript encoders do not
131//! encode backticks. use [`for_js_template`] to embed data directly in
132//! ES2015+ template literals.
133//! - **grave accent.** unpatched Internet Explorer treats `` ` `` as an
134//! attribute delimiter. `for_html_unquoted_attribute` encodes it, but
135//! numeric entities decode back to the original character, so this is
136//! not a complete fix. avoid unquoted attributes.
137//! - **HTML comments.** no HTML comment encoder is provided because HTML
138//! comments have vendor-specific extensions (e.g., conditional comments)
139//! that make safe encoding impractical. [`for_xml_comment`] is for XML
140//! comments only.
141//!
142//! # writer-based API
143//!
144//! every `for_*` function except [`for_rust_char_checked`] has a corresponding
145//! `write_*` function that writes to any `std::fmt::Write` implementor,
146//! avoiding allocation when writing to an existing buffer:
147//!
148//! ```
149//! use contextual_encoder::write_html;
150//!
151//! let mut buf = String::new();
152//! write_html(&mut buf, "safe & sound").unwrap();
153//! assert_eq!(buf, "safe & sound");
154//! ```
155//!
156//! # display wrappers
157//!
158//! those same functions each have a corresponding `display_*` function that
159//! returns a zero-allocation [`Display`](std::fmt::Display) wrapper. use these
160//! when embedding encoded output inline in `format!` or `write!`:
161//!
162//! ```
163//! use contextual_encoder::display_html;
164//!
165//! let user_input = "<script>alert('xss')</script>";
166//! // one allocation (the final String), zero intermediate allocations
167//! let safe = format!("<p>{}</p>", display_html(user_input));
168//! assert!(safe.contains("<script>"));
169//! ```
170
171pub mod css;
172pub mod display;
173pub mod html;
174pub mod javascript;
175pub mod json;
176pub mod rust;
177pub mod sql;
178pub mod uri;
179pub mod xml;
180
181mod engine;
182
183// convenience re-exports — users can `use contextual_encoder::for_html` directly
184pub use css::{for_css_string, for_css_url, write_css_string, write_css_url};
185pub use display::{
186 display_cdata, display_css_string, display_css_url, display_form_urlencoded, display_html,
187 display_html_attribute, display_html_content, display_html_unquoted_attribute,
188 display_javascript, display_javascript_attribute, display_javascript_block,
189 display_javascript_source, display_js_template, display_json, display_rust_byte_string,
190 display_rust_char, display_rust_string, display_sql, display_sql_backslash,
191 display_uri_component, display_uri_path, display_xml, display_xml11, display_xml11_attribute,
192 display_xml11_content, display_xml_attribute, display_xml_comment, display_xml_content,
193};
194pub use html::{
195 for_html, for_html_attribute, for_html_content, for_html_unquoted_attribute, write_html,
196 write_html_attribute, write_html_content, write_html_unquoted_attribute,
197};
198pub use javascript::{
199 for_javascript, for_javascript_attribute, for_javascript_block, for_javascript_source,
200 for_js_template, write_javascript, write_javascript_attribute, write_javascript_block,
201 write_javascript_source, write_js_template,
202};
203pub use json::{for_json, write_json};
204pub use rust::{
205 for_rust_byte_string, for_rust_char, for_rust_char_checked, for_rust_string,
206 write_rust_byte_string, write_rust_char, write_rust_string,
207};
208pub use sql::{for_sql, for_sql_backslash, write_sql, write_sql_backslash};
209pub use uri::{
210 for_form_urlencoded, for_uri_component, for_uri_path, write_form_urlencoded,
211 write_uri_component, write_uri_path,
212};
213pub use xml::{
214 for_cdata, for_xml, for_xml11, for_xml11_attribute, for_xml11_content, for_xml_attribute,
215 for_xml_comment, for_xml_content, write_cdata, write_xml, write_xml11, write_xml11_attribute,
216 write_xml11_content, write_xml_attribute, write_xml_comment, write_xml_content,
217};
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn empty_string_returns_empty() {
225 assert_eq!(for_html(""), "");
226 assert_eq!(for_html_content(""), "");
227 assert_eq!(for_html_attribute(""), "");
228 assert_eq!(for_html_unquoted_attribute(""), "");
229 assert_eq!(for_javascript(""), "");
230 assert_eq!(for_javascript_attribute(""), "");
231 assert_eq!(for_javascript_block(""), "");
232 assert_eq!(for_javascript_source(""), "");
233 assert_eq!(for_css_string(""), "");
234 assert_eq!(for_css_url(""), "");
235 assert_eq!(for_uri_component(""), "");
236 assert_eq!(for_uri_path(""), "");
237 assert_eq!(for_xml(""), "");
238 assert_eq!(for_xml_content(""), "");
239 assert_eq!(for_xml_attribute(""), "");
240 assert_eq!(for_xml_comment(""), "");
241 assert_eq!(for_cdata(""), "");
242 assert_eq!(for_xml11(""), "");
243 assert_eq!(for_xml11_content(""), "");
244 assert_eq!(for_xml11_attribute(""), "");
245 assert_eq!(for_json(""), "");
246 assert_eq!(for_rust_string(""), "");
247 assert_eq!(for_rust_char(""), "");
248 assert_eq!(for_rust_byte_string(""), "");
249 assert_eq!(for_js_template(""), "");
250 assert_eq!(for_sql(""), "");
251 assert_eq!(for_sql_backslash(""), "");
252 assert_eq!(for_form_urlencoded(""), "");
253 }
254
255 #[test]
256 fn empty_string_writer_variants() {
257 let mut buf = String::new();
258 write_html(&mut buf, "").unwrap();
259 assert_eq!(buf, "");
260
261 buf.clear();
262 write_javascript(&mut buf, "").unwrap();
263 assert_eq!(buf, "");
264
265 buf.clear();
266 write_css_string(&mut buf, "").unwrap();
267 assert_eq!(buf, "");
268
269 buf.clear();
270 write_uri_component(&mut buf, "").unwrap();
271 assert_eq!(buf, "");
272
273 buf.clear();
274 write_uri_path(&mut buf, "").unwrap();
275 assert_eq!(buf, "");
276
277 buf.clear();
278 write_form_urlencoded(&mut buf, "").unwrap();
279 assert_eq!(buf, "");
280 }
281
282 // two-byte: é (U+00E9), ñ (U+00F1)
283 // three-byte: 世 (U+4E16), € (U+20AC)
284 // four-byte: 😀 (U+1F600), 𐍈 (U+10348)
285
286 #[test]
287 fn multibyte_utf8_html() {
288 assert_eq!(for_html("café"), "café");
289 assert_eq!(for_html("世界"), "世界");
290 assert_eq!(for_html("😀"), "😀");
291 assert_eq!(for_html("é<世>&😀"), "é<世>&😀");
292 }
293
294 #[test]
295 fn multibyte_utf8_javascript() {
296 assert_eq!(for_javascript("café"), "café");
297 assert_eq!(for_javascript("世界"), "世界");
298 assert_eq!(for_javascript("😀"), "😀");
299 }
300
301 #[test]
302 fn multibyte_utf8_css_string() {
303 assert_eq!(for_css_string("café"), "café");
304 assert_eq!(for_css_string("世界"), "世界");
305 assert_eq!(for_css_string("😀"), "😀");
306 }
307
308 #[test]
309 fn multibyte_utf8_uri_component() {
310 assert_eq!(for_uri_component("é"), "%C3%A9");
311 assert_eq!(for_uri_component("世"), "%E4%B8%96");
312 assert_eq!(for_uri_component("😀"), "%F0%9F%98%80");
313 assert_eq!(for_uri_component("café"), "caf%C3%A9");
314 }
315
316 #[test]
317 fn multibyte_utf8_uri_path() {
318 assert_eq!(for_uri_path("é"), "%C3%A9");
319 assert_eq!(for_uri_path("世"), "%E4%B8%96");
320 assert_eq!(for_uri_path("😀"), "%F0%9F%98%80");
321 assert_eq!(for_uri_path("/café"), "/caf%C3%A9");
322 }
323
324 #[test]
325 fn multibyte_utf8_form_urlencoded() {
326 assert_eq!(for_form_urlencoded("é"), "%C3%A9");
327 assert_eq!(for_form_urlencoded("世"), "%E4%B8%96");
328 assert_eq!(for_form_urlencoded("😀"), "%F0%9F%98%80");
329 assert_eq!(for_form_urlencoded("café"), "caf%C3%A9");
330 }
331
332 #[test]
333 fn multibyte_utf8_rust_byte_string() {
334 assert_eq!(for_rust_byte_string("é"), r"\xc3\xa9");
335 assert_eq!(for_rust_byte_string("世"), r"\xe4\xb8\x96");
336 assert_eq!(for_rust_byte_string("😀"), r"\xf0\x9f\x98\x80");
337 }
338
339 #[test]
340 fn multibyte_utf8_rust_string_passthrough() {
341 assert_eq!(for_rust_string("café"), "café");
342 assert_eq!(for_rust_string("世界"), "世界");
343 assert_eq!(for_rust_string("😀"), "😀");
344 }
345
346 #[test]
347 fn multibyte_utf8_json() {
348 assert_eq!(for_json("café"), "café");
349 assert_eq!(for_json("世界"), "世界");
350 assert_eq!(for_json("😀"), "😀");
351 }
352
353 #[test]
354 fn multibyte_utf8_sql() {
355 assert_eq!(for_sql("café"), "café");
356 assert_eq!(for_sql("世界"), "世界");
357 assert_eq!(for_sql("😀"), "😀");
358 }
359
360 #[test]
361 fn multibyte_utf8_sql_backslash() {
362 assert_eq!(for_sql_backslash("café"), "café");
363 assert_eq!(for_sql_backslash("世界"), "世界");
364 assert_eq!(for_sql_backslash("😀"), "😀");
365 }
366
367 #[test]
368 fn multibyte_utf8_xml() {
369 assert_eq!(for_xml("café"), "café");
370 assert_eq!(for_xml("世界"), "世界");
371 assert_eq!(for_xml("😀"), "😀");
372 }
373}