Skip to main content

contextual_encoder/
javascript.rs

1//! javascript contextual output encoders.
2//!
3//! provides five encoding contexts:
4//!
5//! - [`for_javascript`] — universal encoder, safe in HTML attributes, script
6//!   blocks, and standalone .js files
7//! - [`for_javascript_attribute`] — optimized for HTML event attributes
8//!   (e.g., `onclick="..."`)
9//! - [`for_javascript_block`] — optimized for `<script>` blocks
10//! - [`for_javascript_source`] — optimized for standalone .js files
11//! - [`for_js_template`] — for ES6 template literal content (`` `...` ``)
12//!
13//! # security notes
14//!
15//! - the string literal encoders ([`for_javascript`], [`for_javascript_attribute`],
16//!   [`for_javascript_block`], [`for_javascript_source`]) do **not** encode the
17//!   grave accent (`` ` ``). do not use them to embed data inside template
18//!   literals — use [`for_js_template`] instead.
19//! - these encoders are for string/template literal contexts only. they cannot
20//!   make arbitrary javascript expressions, variable names, or property
21//!   accessors safe.
22//! - `for_javascript_block` and `for_javascript_source` use backslash escapes
23//!   for quotes (`\"`, `\'`) which are **not safe in HTML attribute contexts**.
24//! - `for_javascript_attribute` does not escape `<` or `/` and is **not safe
25//!   in `<script>` blocks** where `</script>` could appear.
26//! - `for_javascript_source` does not escape `&`, so it is **not safe in an
27//!   XHTML `<script>`**, where character references are decoded before
28//!   javascript sees the text. the other four encoders escape it.
29//! - none of these encoders produce valid JSON — the `\xHH` escapes they emit
30//!   for control characters are not permitted in JSON. use
31//!   [`for_json`](crate::for_json) for JSON string values.
32
33use std::fmt;
34
35use crate::engine::encode_loop;
36
37/// configuration flags controlling context-specific encoding differences.
38#[derive(Clone, Copy)]
39struct JsConfig {
40    /// true: `"` → `\x22`, `'` → `\x27` (safe in HTML attributes).
41    /// false: `"` → `\"`, `'` → `\'` (more readable, not HTML-attr safe).
42    hex_quotes: bool,
43    /// true: encode `&` as `\x26` (prevents HTML entity interpretation).
44    encode_ampersand: bool,
45    /// true: the output can land in HTML script data, so encode every
46    /// character that can move the tokenizer out of it — `<` → `\x3c` and
47    /// `/` → `\/`.
48    script_data: bool,
49}
50
51const JS_UNIVERSAL: JsConfig = JsConfig {
52    hex_quotes: true,
53    encode_ampersand: true,
54    script_data: true,
55};
56
57const JS_ATTRIBUTE: JsConfig = JsConfig {
58    hex_quotes: true,
59    encode_ampersand: true,
60    script_data: false,
61};
62
63const JS_BLOCK: JsConfig = JsConfig {
64    hex_quotes: false,
65    encode_ampersand: true,
66    script_data: true,
67};
68
69const JS_SOURCE: JsConfig = JsConfig {
70    hex_quotes: false,
71    encode_ampersand: false,
72    script_data: false,
73};
74
75/// encodes `input` for safe embedding in a javascript string literal.
76///
77/// this is the universal javascript encoder — its output is safe in HTML
78/// event attributes, `<script>` blocks, and standalone .js files. it is
79/// slightly more conservative than the context-specific encoders.
80///
81/// # encoding rules
82///
83/// - C0 controls → named escapes (`\b`, `\t`, `\n`, `\f`, `\r`) or hex
84///   (`\xHH`)
85/// - `"` → `\x22`, `'` → `\x27` (hex escapes for HTML attribute safety)
86/// - `&` → `\x26` (prevents HTML entity interpretation)
87/// - `<` → `\x3c`, `/` → `\/` (keeps the HTML tokenizer in script data state,
88///   so the enclosing `</script>` still closes the block)
89/// - `\` → `\\`
90/// - U+2028 → `\u2028`, U+2029 → `\u2029` (javascript line terminators)
91///
92/// # caveat: template literals
93///
94/// this encoder does **not** encode the grave accent (`` ` ``). never
95/// embed untrusted data directly inside template literals. instead:
96///
97/// ```js
98/// // WRONG — vulnerable to XSS:
99/// // `Hello ${unsafeInput}`
100/// //
101/// // RIGHT — encode into a variable first:
102/// // var x = '<encoded>';
103/// // `Hello ${x}`
104/// ```
105///
106/// # examples
107///
108/// ```
109/// use contextual_encoder::for_javascript;
110///
111/// assert_eq!(for_javascript(r#"it's "unsafe" </script>"#),
112///            r"it\x27s \x22unsafe\x22 \x3c\/script>");
113/// assert_eq!(for_javascript("safe"), "safe");
114/// ```
115pub fn for_javascript(input: &str) -> String {
116    encode_js(input, &JS_UNIVERSAL)
117}
118
119/// writes the javascript-encoded form of `input` to `out`.
120///
121/// see [`for_javascript`] for encoding rules.
122pub fn write_javascript<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
123    write_js(out, input, &JS_UNIVERSAL)
124}
125
126/// encodes `input` for safe embedding in a javascript string literal inside
127/// an HTML event attribute (e.g., `onclick="..."`).
128///
129/// identical to [`for_javascript`] except `<` and `/` are **not** escaped. an
130/// attribute value is never tokenized as script data, so neither character can
131/// affect where the enclosing element ends.
132///
133/// **not safe in `<script>` blocks** — use [`for_javascript`] or
134/// [`for_javascript_block`] instead.
135///
136/// # examples
137///
138/// ```
139/// use contextual_encoder::for_javascript_attribute;
140///
141/// assert_eq!(for_javascript_attribute("a/b"), "a/b");
142/// assert_eq!(for_javascript_attribute("a<b"), "a<b");
143/// assert_eq!(for_javascript_attribute("a'b"), r"a\x27b");
144/// ```
145pub fn for_javascript_attribute(input: &str) -> String {
146    encode_js(input, &JS_ATTRIBUTE)
147}
148
149/// writes the javascript-attribute-encoded form of `input` to `out`.
150///
151/// see [`for_javascript_attribute`] for encoding rules.
152pub fn write_javascript_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
153    write_js(out, input, &JS_ATTRIBUTE)
154}
155
156/// encodes `input` for safe embedding in a javascript string literal inside
157/// an HTML `<script>` block.
158///
159/// uses backslash escapes for quotes (`\"`, `\'`) which are more readable
160/// but **not safe in HTML attribute contexts**. still encodes `&` (for XHTML
161/// compatibility) and `<`/`/`, which keep the HTML tokenizer in script data
162/// state so the enclosing `</script>` still closes the block.
163///
164/// # examples
165///
166/// ```
167/// use contextual_encoder::for_javascript_block;
168///
169/// assert_eq!(for_javascript_block(r#"he said "hi""#), r#"he said \"hi\""#);
170/// assert_eq!(for_javascript_block("</script>"), r"\x3c\/script>");
171/// assert_eq!(for_javascript_block("<!--<script>"), r"\x3c!--\x3cscript>");
172/// ```
173pub fn for_javascript_block(input: &str) -> String {
174    encode_js(input, &JS_BLOCK)
175}
176
177/// writes the javascript-block-encoded form of `input` to `out`.
178///
179/// see [`for_javascript_block`] for encoding rules.
180pub fn write_javascript_block<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
181    write_js(out, input, &JS_BLOCK)
182}
183
184/// encodes `input` for safe embedding in a javascript string literal in a
185/// standalone .js file.
186///
187/// the most minimal javascript encoder — does not encode `<`, `/` or `&`
188/// since a standalone .js file is never HTML-tokenized. **not safe for any
189/// HTML-embedded context.**
190///
191/// **not a JSON encoder.** it emits `\'` for single quotes and `\xHH` for
192/// control characters, neither of which JSON permits. use
193/// [`for_json`](crate::for_json) for JSON string values.
194///
195/// # examples
196///
197/// ```
198/// use contextual_encoder::{for_javascript_source, for_json};
199///
200/// assert_eq!(for_javascript_source("a/b&c<d"), "a/b&c<d");
201/// assert_eq!(for_javascript_source("line\nbreak"), r"line\nbreak");
202///
203/// assert_eq!(for_javascript_source("it's"), r"it\'s");
204/// assert_eq!(for_json("it's"), "it's");
205/// ```
206pub fn for_javascript_source(input: &str) -> String {
207    encode_js(input, &JS_SOURCE)
208}
209
210/// writes the javascript-source-encoded form of `input` to `out`.
211///
212/// see [`for_javascript_source`] for encoding rules.
213pub fn write_javascript_source<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
214    write_js(out, input, &JS_SOURCE)
215}
216
217/// encodes `input` for safe embedding inside an ES6 template literal
218/// (`` `...` ``).
219///
220/// template literals use backticks as delimiters and `${...}` for
221/// interpolation. this encoder escapes both so untrusted data cannot break
222/// out of the literal or inject expressions.
223///
224/// # encoding rules
225///
226/// - `` ` `` → `` \` `` (prevents breaking out of the template literal)
227/// - `$` followed by `{`, and `$` at the end of the input → `\$` (prevents
228///   expression interpolation, including a `${` the caller completes)
229/// - `\` → `\\`
230/// - `<` → `\x3c`, `/` → `\/` (keeps the HTML tokenizer in script data state,
231///   so the enclosing `</script>` still closes the block)
232/// - `&` → `\x26` (stops an XHTML parser decoding a character reference into
233///   a `` ` `` or a `${` before javascript sees the text)
234/// - C0 controls → named escapes (`\b`, `\t`, `\n`, `\f`, `\r`) or hex
235///   (`\xHH`)
236/// - U+2028 → `\u2028`, U+2029 → `\u2029` (line/paragraph separators)
237///
238/// unlike the string literal encoders, this does **not** escape `"` or `'`
239/// (they are ordinary characters inside template literals).
240///
241/// # examples
242///
243/// ```
244/// use contextual_encoder::for_js_template;
245///
246/// assert_eq!(for_js_template("hello `world`"), r"hello \`world\`");
247/// assert_eq!(for_js_template("${alert(1)}"), r"\${alert(1)}");
248/// assert_eq!(for_js_template("safe"), "safe");
249/// // `\x26` is `&` in a template literal, so the decoded value is unchanged
250/// assert_eq!(for_js_template("a&b"), r"a\x26b");
251/// assert_eq!(for_js_template("&#96;"), r"\x26#96;");
252/// assert_eq!(for_js_template("a $ b"), "a $ b");
253/// // `\$` is `$` in a template literal, so the decoded value is unchanged
254/// assert_eq!(for_js_template("cost: $"), r"cost: \$");
255/// ```
256pub fn for_js_template(input: &str) -> String {
257    let mut out = String::with_capacity(input.len());
258    write_js_template(&mut out, input).expect("writing to string cannot fail");
259    out
260}
261
262/// writes the template-literal-encoded form of `input` to `out`.
263///
264/// see [`for_js_template`] for encoding rules.
265pub fn write_js_template<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
266    encode_loop(
267        out,
268        input,
269        needs_js_template_encoding,
270        write_js_template_encoded,
271    )
272}
273
274fn needs_js_template_encoding(c: char) -> bool {
275    matches!(
276        c,
277        '\x00'..='\x1F' | '\\' | '`' | '$' | '&' | '/' | '<' | '\u{2028}' | '\u{2029}'
278    )
279}
280
281fn write_js_template_encoded<W: fmt::Write>(
282    out: &mut W,
283    c: char,
284    next: Option<char>,
285) -> fmt::Result {
286    match c {
287        // template-specific characters
288        '`' => out.write_str("\\`"),
289        '$' if matches!(next, Some('{') | None) => out.write_str("\\$"),
290        '$' => out.write_char('$'),
291        '&' => out.write_str("\\x26"),
292        '/' => out.write_str("\\/"),
293        '<' => out.write_str("\\x3c"),
294        // C0 controls, backslash, and line separators
295        c => write_js_shared_escape(out, c),
296    }
297}
298
299fn encode_js(input: &str, config: &JsConfig) -> String {
300    let mut out = String::with_capacity(input.len());
301    write_js(&mut out, input, config).expect("writing to string cannot fail");
302    out
303}
304
305fn write_js<W: fmt::Write>(out: &mut W, input: &str, config: &JsConfig) -> fmt::Result {
306    encode_loop(
307        out,
308        input,
309        |c| needs_js_encoding(c, config),
310        |out, c, _next| write_js_encoded(out, c, config),
311    )
312}
313
314fn needs_js_encoding(c: char, config: &JsConfig) -> bool {
315    match c {
316        '\x00'..='\x1F' | '\\' | '"' | '\'' | '\u{2028}' | '\u{2029}' => true,
317        '&' => config.encode_ampersand,
318        '/' | '<' => config.script_data,
319        _ => false,
320    }
321}
322
323fn write_js_encoded<W: fmt::Write>(out: &mut W, c: char, config: &JsConfig) -> fmt::Result {
324    match c {
325        // string-literal-specific characters
326        '"' if config.hex_quotes => out.write_str("\\x22"),
327        '"' => out.write_str("\\\""),
328        '\'' if config.hex_quotes => out.write_str("\\x27"),
329        '\'' => out.write_str("\\'"),
330        '&' => out.write_str("\\x26"),
331        '/' => out.write_str("\\/"),
332        '<' => out.write_str("\\x3c"),
333        // C0 controls, backslash, and line separators
334        c => write_js_shared_escape(out, c),
335    }
336}
337
338/// writes the C0-control/backslash/line-separator escape shared by both js
339/// encoders. any other character falls back to `\u{...}` so a character a
340/// predicate flags can never be dropped from the output.
341fn write_js_shared_escape<W: fmt::Write>(out: &mut W, c: char) -> fmt::Result {
342    match c {
343        '\x08' => out.write_str("\\b"),
344        '\t' => out.write_str("\\t"),
345        '\n' => out.write_str("\\n"),
346        '\x0B' => out.write_str("\\x0b"),
347        '\x0C' => out.write_str("\\f"),
348        '\r' => out.write_str("\\r"),
349        '\\' => out.write_str("\\\\"),
350        '\u{2028}' => out.write_str("\\u2028"),
351        '\u{2029}' => out.write_str("\\u2029"),
352        '\x00'..='\x1F' => write!(out, "\\x{:02x}", c as u32),
353        c => write!(out, "\\u{{{:x}}}", c as u32),
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    // -- for_javascript (universal) --
362
363    #[test]
364    fn js_no_encoding_needed() {
365        assert_eq!(for_javascript("hello world"), "hello world");
366        assert_eq!(for_javascript(""), "");
367    }
368
369    #[test]
370    fn js_encodes_quotes_as_hex() {
371        assert_eq!(for_javascript(r#"a"b"#), r"a\x22b");
372        assert_eq!(for_javascript("a'b"), r"a\x27b");
373    }
374
375    #[test]
376    fn js_encodes_backslash() {
377        assert_eq!(for_javascript(r"a\b"), r"a\\b");
378    }
379
380    #[test]
381    fn js_encodes_ampersand() {
382        assert_eq!(for_javascript("a&b"), r"a\x26b");
383    }
384
385    #[test]
386    fn js_encodes_slash() {
387        assert_eq!(for_javascript("a/b"), r"a\/b");
388        assert_eq!(for_javascript("</script>"), r"\x3c\/script>");
389    }
390
391    #[test]
392    fn js_encodes_lt() {
393        assert_eq!(for_javascript("a<b"), r"a\x3cb");
394        assert_eq!(for_javascript("<!--<script>"), r"\x3c!--\x3cscript>");
395        assert_eq!(for_javascript("<!--"), r"\x3c!--");
396        assert_eq!(for_javascript("<script"), r"\x3cscript");
397    }
398
399    #[test]
400    fn js_encodes_control_chars() {
401        assert_eq!(for_javascript("\x00"), r"\x00");
402        assert_eq!(for_javascript("\x08"), r"\b");
403        assert_eq!(for_javascript("\t"), r"\t");
404        assert_eq!(for_javascript("\n"), r"\n");
405        assert_eq!(for_javascript("\x0B"), r"\x0b");
406        assert_eq!(for_javascript("\x0C"), r"\f");
407        assert_eq!(for_javascript("\r"), r"\r");
408        assert_eq!(for_javascript("\x1F"), r"\x1f");
409    }
410
411    #[test]
412    fn js_encodes_line_separators() {
413        assert_eq!(for_javascript("\u{2028}"), r"\u2028");
414        assert_eq!(for_javascript("\u{2029}"), r"\u2029");
415    }
416
417    #[test]
418    fn js_preserves_non_ascii() {
419        assert_eq!(for_javascript("café"), "café");
420        assert_eq!(for_javascript("日本語"), "日本語");
421    }
422
423    #[test]
424    fn js_writer_variant() {
425        let mut out = String::new();
426        write_javascript(&mut out, "a'b").unwrap();
427        assert_eq!(out, r"a\x27b");
428    }
429
430    // -- for_javascript_attribute --
431
432    #[test]
433    fn js_attr_does_not_encode_slash_or_lt() {
434        assert_eq!(for_javascript_attribute("a/b"), "a/b");
435        assert_eq!(for_javascript_attribute("<!--<script>"), "<!--<script>");
436    }
437
438    #[test]
439    fn js_attr_encodes_quotes_as_hex() {
440        assert_eq!(for_javascript_attribute("a'b"), r"a\x27b");
441    }
442
443    #[test]
444    fn js_attr_encodes_ampersand() {
445        assert_eq!(for_javascript_attribute("a&b"), r"a\x26b");
446    }
447
448    // -- for_javascript_block --
449
450    #[test]
451    fn js_block_uses_backslash_quotes() {
452        assert_eq!(for_javascript_block(r#"a"b"#), r#"a\"b"#);
453        assert_eq!(for_javascript_block("a'b"), r"a\'b");
454    }
455
456    #[test]
457    fn js_block_encodes_slash() {
458        assert_eq!(for_javascript_block("a/b"), r"a\/b");
459    }
460
461    #[test]
462    fn js_block_encodes_lt() {
463        assert_eq!(for_javascript_block("a<b"), r"a\x3cb");
464        assert_eq!(for_javascript_block("<!--<script>"), r"\x3c!--\x3cscript>");
465        assert_eq!(for_javascript_block("<!--"), r"\x3c!--");
466        assert_eq!(for_javascript_block("<script"), r"\x3cscript");
467    }
468
469    #[test]
470    fn js_block_encodes_ampersand() {
471        assert_eq!(for_javascript_block("a&b"), r"a\x26b");
472    }
473
474    // -- for_javascript_source --
475
476    #[test]
477    fn js_source_uses_backslash_quotes() {
478        assert_eq!(for_javascript_source(r#"a"b"#), r#"a\"b"#);
479        assert_eq!(for_javascript_source("a'b"), r"a\'b");
480    }
481
482    #[test]
483    fn js_source_does_not_encode_slash_ampersand_or_lt() {
484        assert_eq!(for_javascript_source("a/b&c"), "a/b&c");
485        assert_eq!(for_javascript_source("<!--<script>"), "<!--<script>");
486    }
487
488    #[test]
489    fn js_source_encodes_line_separators() {
490        assert_eq!(for_javascript_source("\u{2028}"), r"\u2028");
491    }
492
493    // -- for_js_template --
494
495    #[test]
496    fn js_template_no_encoding_needed() {
497        assert_eq!(for_js_template("hello world"), "hello world");
498        assert_eq!(for_js_template(""), "");
499    }
500
501    #[test]
502    fn js_template_encodes_backtick() {
503        assert_eq!(for_js_template("hello `world`"), r"hello \`world\`");
504        assert_eq!(for_js_template("`"), r"\`");
505    }
506
507    #[test]
508    fn js_template_encodes_interpolation() {
509        assert_eq!(for_js_template("${alert(1)}"), r"\${alert(1)}");
510        assert_eq!(for_js_template("a${b}c"), r"a\${b}c");
511        assert_eq!(for_js_template("${a}${b}"), r"\${a}\${b}");
512    }
513
514    #[test]
515    fn js_template_dollar_without_brace_passes_through() {
516        assert_eq!(for_js_template("a $ b"), "a $ b");
517        assert_eq!(for_js_template("$100"), "$100");
518    }
519
520    #[test]
521    fn js_template_escapes_trailing_dollar() {
522        assert_eq!(for_js_template("a$"), r"a\$");
523        assert_eq!(for_js_template("$"), r"\$");
524    }
525
526    #[test]
527    fn js_template_encodes_backslash() {
528        assert_eq!(for_js_template(r"a\b"), r"a\\b");
529    }
530
531    #[test]
532    fn js_template_encodes_slash() {
533        assert_eq!(for_js_template("a/b"), r"a\/b");
534        assert_eq!(for_js_template("</script>"), r"\x3c\/script>");
535    }
536
537    #[test]
538    fn js_template_encodes_lt() {
539        assert_eq!(for_js_template("a<b"), r"a\x3cb");
540        assert_eq!(for_js_template("<!--<script>"), r"\x3c!--\x3cscript>");
541        assert_eq!(for_js_template("<!--"), r"\x3c!--");
542        assert_eq!(for_js_template("<script"), r"\x3cscript");
543    }
544
545    #[test]
546    fn js_template_does_not_encode_quotes() {
547        assert_eq!(for_js_template(r#"a"b"#), r#"a"b"#);
548        assert_eq!(for_js_template("a'b"), "a'b");
549    }
550
551    #[test]
552    fn js_template_encodes_control_chars() {
553        assert_eq!(for_js_template("\x00"), r"\x00");
554        assert_eq!(for_js_template("\x08"), r"\b");
555        assert_eq!(for_js_template("\t"), r"\t");
556        assert_eq!(for_js_template("\n"), r"\n");
557        assert_eq!(for_js_template("\x0B"), r"\x0b");
558        assert_eq!(for_js_template("\x0C"), r"\f");
559        assert_eq!(for_js_template("\r"), r"\r");
560        assert_eq!(for_js_template("\x1F"), r"\x1f");
561    }
562
563    #[test]
564    fn js_template_encodes_line_separators() {
565        assert_eq!(for_js_template("\u{2028}"), r"\u2028");
566        assert_eq!(for_js_template("\u{2029}"), r"\u2029");
567    }
568
569    #[test]
570    fn js_template_preserves_non_ascii() {
571        assert_eq!(for_js_template("café"), "café");
572        assert_eq!(for_js_template("日本語"), "日本語");
573        assert_eq!(for_js_template("😀"), "😀");
574    }
575
576    #[test]
577    fn js_template_mixed_input() {
578        assert_eq!(
579            for_js_template("`Hello ${name}`, welcome\\n"),
580            r"\`Hello \${name}\`, welcome\\n"
581        );
582    }
583
584    #[test]
585    fn js_template_writer_variant() {
586        let input = "`test` ${x} café";
587        let string_result = for_js_template(input);
588        let mut writer_result = String::new();
589        write_js_template(&mut writer_result, input).unwrap();
590        assert_eq!(string_result, writer_result);
591    }
592
593    // -- write_js_shared_escape helper --
594
595    #[test]
596    fn shared_escape_handles_shared_chars() {
597        let cases = [
598            ('\x08', r"\b"),
599            ('\t', r"\t"),
600            ('\n', r"\n"),
601            ('\x0B', r"\x0b"),
602            ('\x0C', r"\f"),
603            ('\r', r"\r"),
604            ('\\', r"\\"),
605            ('\x00', r"\x00"),
606            ('\x1F', r"\x1f"),
607            ('\u{2028}', r"\u2028"),
608            ('\u{2029}', r"\u2029"),
609        ];
610        for (c, expected) in cases {
611            let mut out = String::new();
612            assert_eq!(write_js_shared_escape(&mut out, c), Ok(()));
613            assert_eq!(out, expected);
614        }
615    }
616
617    #[test]
618    fn shared_escape_never_drops_a_character() {
619        for (c, expected) in [('a', r"\u{61}"), ('"', r"\u{22}"), ('é', r"\u{e9}")] {
620            let mut out = String::new();
621            assert_eq!(write_js_shared_escape(&mut out, c), Ok(()));
622            assert_eq!(out, expected);
623        }
624    }
625}