1use super::invisible::{
4 circled_letter_encode, ligature_encode, parenthesized_letter_encode, soft_hyphen_inject,
5 tag_char_encode, variation_selector_pad, variation_selector_supplementary_pad,
6 word_joiner_wrap,
7};
8use super::keyword::{
9 between_obfuscate, case_alternate, mysql_versioned_comment, percentage_prefix,
10 random_case_alternate, space_to_comment, space_to_dash, space_to_hash, space_to_plus,
11 space_to_random_blank, sql_comment_insert, unmagic_quotes, whitespace_insert,
12};
13use super::structural::{
14 base64_encode, base64_url_encode, chunked_split, deflate_encode, gzip_encode, hex_encode,
15 null_byte_inject, overlong_utf8, overlong_utf8_more, parameter_pollute, utf7_encode,
16};
17use super::unicode::{
18 fullwidth_encode, homoglyph_encode, html_entity_decimal_encode, html_entity_encode,
19 iis_unicode_encode, json_string_encode, unicode_encode,
20};
21use super::url::{double_url_encode, triple_url_encode, url_encode, url_encode_lower};
22use crate::error::EncodeError;
23
24pub const MAX_PAYLOAD_SIZE: usize = 8 * 1024 * 1024;
26
27pub const CHUNKED_SPLIT_DEFAULT_CHUNK_SIZE: usize = 1024;
34
35pub const MYSQL_VERSIONED_COMMENT_VERSION: u32 = 50_000;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
50#[non_exhaustive]
51pub enum Strategy {
52 UrlEncode,
55 UrlEncodeLower,
58 DoubleUrlEncode,
61 TripleUrlEncode,
64 UnicodeEncode,
67 IisUnicodeEncode,
70 JsonEncode,
73 HtmlEntityEncode,
76 HtmlEntityDecimalEncode,
79 CaseAlternation,
82 RandomCase,
85 WhitespaceInsertion,
88 SqlCommentInsertion,
91 MysqlVersionedComment,
94 NullByte,
97 OverlongUtf8,
100 OverlongUtf8More,
103 ChunkedSplit,
106 ParameterPollution,
109 Base64Encode,
112 Base64UrlEncode,
115 HexEncode,
118 Utf7Encode,
121 GzipEncode,
124 DeflateEncode,
127 SpaceToComment,
130 SpaceToDash,
133 SpaceToHash,
136 SpaceToPlus,
139 SpaceToRandomBlank,
142 PercentagePrefix,
145 BetweenObfuscation,
148 UnmagicQuotes,
151 FullwidthEncode,
154 HomoglyphEncode,
157 TagCharEncode,
162 VariationSelectorPad,
166 VariationSelectorSupplementaryPad,
171 LigatureEncode,
176 CircledLetterEncode,
180 ParenthesizedLetterEncode,
186 SoftHyphenInject,
190 WordJoinerWrap,
194}
195
196impl Strategy {
197 #[must_use]
199 pub const fn as_str(&self) -> &'static str {
200 match self {
201 Self::UrlEncode => "UrlEncode",
202 Self::UrlEncodeLower => "UrlEncodeLower",
203 Self::DoubleUrlEncode => "DoubleUrlEncode",
204 Self::TripleUrlEncode => "TripleUrlEncode",
205 Self::UnicodeEncode => "UnicodeEncode",
206 Self::IisUnicodeEncode => "IisUnicodeEncode",
207 Self::JsonEncode => "JsonEncode",
208 Self::HtmlEntityEncode => "HtmlEntityEncode",
209 Self::HtmlEntityDecimalEncode => "HtmlEntityDecimalEncode",
210 Self::CaseAlternation => "CaseAlternation",
211 Self::RandomCase => "RandomCase",
212 Self::WhitespaceInsertion => "WhitespaceInsertion",
213 Self::SqlCommentInsertion => "SqlCommentInsertion",
214 Self::MysqlVersionedComment => "MysqlVersionedComment",
215 Self::NullByte => "NullByte",
216 Self::OverlongUtf8 => "OverlongUtf8",
217 Self::OverlongUtf8More => "OverlongUtf8More",
218 Self::ChunkedSplit => "ChunkedSplit",
219 Self::ParameterPollution => "ParameterPollution",
220 Self::Base64Encode => "Base64Encode",
221 Self::Base64UrlEncode => "Base64UrlEncode",
222 Self::HexEncode => "HexEncode",
223 Self::Utf7Encode => "Utf7Encode",
224 Self::GzipEncode => "GzipEncode",
225 Self::DeflateEncode => "DeflateEncode",
226 Self::SpaceToComment => "SpaceToComment",
227 Self::SpaceToDash => "SpaceToDash",
228 Self::SpaceToHash => "SpaceToHash",
229 Self::SpaceToPlus => "SpaceToPlus",
230 Self::SpaceToRandomBlank => "SpaceToRandomBlank",
231 Self::PercentagePrefix => "PercentagePrefix",
232 Self::BetweenObfuscation => "BetweenObfuscation",
233 Self::UnmagicQuotes => "UnmagicQuotes",
234 Self::FullwidthEncode => "FullwidthEncode",
235 Self::HomoglyphEncode => "HomoglyphEncode",
236 Self::TagCharEncode => "TagCharEncode",
237 Self::VariationSelectorPad => "VariationSelectorPad",
238 Self::VariationSelectorSupplementaryPad => "VariationSelectorSupplementaryPad",
239 Self::LigatureEncode => "LigatureEncode",
240 Self::CircledLetterEncode => "CircledLetterEncode",
241 Self::ParenthesizedLetterEncode => "ParenthesizedLetterEncode",
242 Self::SoftHyphenInject => "SoftHyphenInject",
243 Self::WordJoinerWrap => "WordJoinerWrap",
244 }
245 }
246
247 #[must_use]
253 pub const fn contexts(&self) -> &'static [&'static str] {
254 match self {
255 Self::UrlEncode
256 | Self::UrlEncodeLower
257 | Self::DoubleUrlEncode
258 | Self::TripleUrlEncode
259 | Self::ParameterPollution => &[],
260 Self::UnicodeEncode => &["json", "javascript"],
261 Self::IisUnicodeEncode => &["iis", "asp"],
262 Self::JsonEncode => &["json"],
263 Self::HtmlEntityEncode | Self::HtmlEntityDecimalEncode => &["html"],
264 Self::CaseAlternation | Self::RandomCase | Self::WhitespaceInsertion => &[],
265 Self::SqlCommentInsertion
266 | Self::MysqlVersionedComment
267 | Self::SpaceToComment
268 | Self::SpaceToDash
269 | Self::SpaceToRandomBlank
270 | Self::BetweenObfuscation => &["sql"],
271 Self::SpaceToHash => &["sql", "mysql"],
272 Self::SpaceToPlus => &["url-encoded"],
273 Self::NullByte => &["php", "cgi"],
274 Self::OverlongUtf8 | Self::OverlongUtf8More => &["iis-6"],
275 Self::ChunkedSplit => &["http-request-body"],
276 Self::Base64Encode | Self::Base64UrlEncode | Self::HexEncode => &[],
277 Self::Utf7Encode => &["iis", "legacy-dotnet"],
278 Self::GzipEncode | Self::DeflateEncode => &["http-request-body"],
279 Self::PercentagePrefix => &[],
280 Self::UnmagicQuotes => &["php", "gbk", "big5", "shift-jis"],
281 Self::FullwidthEncode => &["nfkc", "java", "dotnet", "python3", "postgresql"],
282 Self::HomoglyphEncode => &[],
283 Self::TagCharEncode
284 | Self::VariationSelectorPad
285 | Self::VariationSelectorSupplementaryPad
286 | Self::SoftHyphenInject
287 | Self::WordJoinerWrap => &[],
288 Self::LigatureEncode | Self::CircledLetterEncode | Self::ParenthesizedLetterEncode => {
289 &["nfkc"]
290 }
291 }
292 }
293}
294
295fn check_size(payload: &[u8]) -> Result<(), EncodeError> {
296 if payload.len() > MAX_PAYLOAD_SIZE {
297 Err(EncodeError::PayloadTooLarge {
298 max: MAX_PAYLOAD_SIZE,
299 actual: payload.len(),
300 })
301 } else {
302 Ok(())
303 }
304}
305
306pub fn encode(payload: impl AsRef<[u8]>, strategy: Strategy) -> Result<String, EncodeError> {
318 let payload = payload.as_ref();
319 check_size(payload)?;
320
321 match strategy {
322 Strategy::UrlEncode => Ok(url_encode(payload)),
323 Strategy::UrlEncodeLower => Ok(url_encode_lower(payload)),
324 Strategy::DoubleUrlEncode => Ok(double_url_encode(payload)),
325 Strategy::TripleUrlEncode => Ok(triple_url_encode(payload)),
326 Strategy::UnicodeEncode => {
327 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
328 Ok(unicode_encode(text))
329 }
330 Strategy::IisUnicodeEncode => {
331 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
332 Ok(iis_unicode_encode(text))
333 }
334 Strategy::JsonEncode => {
335 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
336 Ok(json_string_encode(text))
337 }
338 Strategy::HtmlEntityEncode => {
339 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
340 Ok(html_entity_encode(text))
341 }
342 Strategy::HtmlEntityDecimalEncode => {
343 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
344 Ok(html_entity_decimal_encode(text))
345 }
346 Strategy::CaseAlternation => {
347 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
348 Ok(case_alternate(text))
349 }
350 Strategy::RandomCase => {
351 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
352 Ok(random_case_alternate(text))
353 }
354 Strategy::WhitespaceInsertion => {
355 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
356 Ok(whitespace_insert(text))
357 }
358 Strategy::SqlCommentInsertion => {
359 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
360 Ok(sql_comment_insert(text))
361 }
362 Strategy::MysqlVersionedComment => {
363 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
364 Ok(mysql_versioned_comment(
365 text,
366 MYSQL_VERSIONED_COMMENT_VERSION,
367 ))
368 }
369 Strategy::NullByte => Ok(null_byte_inject(payload)?),
370 Strategy::OverlongUtf8 => Ok(overlong_utf8(payload)?),
371 Strategy::OverlongUtf8More => Ok(overlong_utf8_more(payload)?),
372 Strategy::ChunkedSplit => {
373 let body = chunked_split(payload, CHUNKED_SPLIT_DEFAULT_CHUNK_SIZE)?.body;
374 String::from_utf8(body).map_err(|_| EncodeError::InvalidUtf8)
375 }
376 Strategy::ParameterPollution => Ok(parameter_pollute(payload)?),
377 Strategy::Base64Encode => Ok(base64_encode(payload)),
378 Strategy::Base64UrlEncode => Ok(base64_url_encode(payload)),
379 Strategy::HexEncode => Ok(hex_encode(payload)),
380 Strategy::Utf7Encode => {
381 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
382 Ok(utf7_encode(text))
383 }
384 Strategy::GzipEncode => Ok(gzip_encode(payload)?),
385 Strategy::DeflateEncode => Ok(deflate_encode(payload)?),
386 Strategy::SpaceToComment => {
387 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
388 Ok(space_to_comment(text))
389 }
390 Strategy::SpaceToDash => {
391 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
392 Ok(space_to_dash(text))
393 }
394 Strategy::SpaceToHash => {
395 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
396 Ok(space_to_hash(text))
397 }
398 Strategy::SpaceToPlus => {
399 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
400 Ok(space_to_plus(text))
401 }
402 Strategy::SpaceToRandomBlank => {
403 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
404 Ok(space_to_random_blank(text))
405 }
406 Strategy::PercentagePrefix => {
407 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
408 Ok(percentage_prefix(text))
409 }
410 Strategy::BetweenObfuscation => {
411 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
412 Ok(between_obfuscate(text))
413 }
414 Strategy::UnmagicQuotes => Ok(unmagic_quotes(payload)?),
415 Strategy::FullwidthEncode => {
416 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
417 Ok(fullwidth_encode(text))
418 }
419 Strategy::HomoglyphEncode => {
420 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
421 Ok(homoglyph_encode(text))
422 }
423 Strategy::TagCharEncode => {
424 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
425 Ok(tag_char_encode(text))
426 }
427 Strategy::VariationSelectorPad => {
428 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
429 Ok(variation_selector_pad(text, '\u{FE0F}'))
430 }
431 Strategy::VariationSelectorSupplementaryPad => {
432 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
433 Ok(variation_selector_supplementary_pad(text))
434 }
435 Strategy::LigatureEncode => {
436 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
437 Ok(ligature_encode(text))
438 }
439 Strategy::CircledLetterEncode => {
440 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
441 Ok(circled_letter_encode(text))
442 }
443 Strategy::ParenthesizedLetterEncode => {
444 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
445 Ok(parenthesized_letter_encode(text))
446 }
447 Strategy::SoftHyphenInject => {
448 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
449 Ok(soft_hyphen_inject(text))
450 }
451 Strategy::WordJoinerWrap => {
452 let text = std::str::from_utf8(payload).map_err(|_| EncodeError::InvalidUtf8)?;
453 Ok(word_joiner_wrap(text))
454 }
455 }
456}
457
458static ALL_STRATEGIES: std::sync::LazyLock<Vec<Strategy>> = std::sync::LazyLock::new(|| {
460 let mut strategies = vec![
461 Strategy::CaseAlternation,
462 Strategy::RandomCase,
463 Strategy::WhitespaceInsertion,
464 Strategy::SqlCommentInsertion,
465 Strategy::SpaceToPlus,
466 Strategy::SpaceToRandomBlank,
467 Strategy::SpaceToComment,
468 Strategy::SpaceToDash,
469 Strategy::SpaceToHash,
470 Strategy::UrlEncode,
471 Strategy::UrlEncodeLower,
472 Strategy::DoubleUrlEncode,
473 Strategy::UnicodeEncode,
474 Strategy::IisUnicodeEncode,
475 Strategy::JsonEncode,
476 Strategy::HtmlEntityEncode,
477 Strategy::HtmlEntityDecimalEncode,
478 Strategy::NullByte,
479 Strategy::PercentagePrefix,
480 Strategy::TripleUrlEncode,
481 Strategy::ChunkedSplit,
482 Strategy::ParameterPollution,
483 Strategy::MysqlVersionedComment,
484 Strategy::Base64Encode,
485 Strategy::Base64UrlEncode,
486 Strategy::OverlongUtf8,
487 Strategy::OverlongUtf8More,
488 Strategy::HexEncode,
489 Strategy::Utf7Encode,
490 Strategy::BetweenObfuscation,
491 Strategy::UnmagicQuotes,
492 Strategy::FullwidthEncode,
493 Strategy::HomoglyphEncode,
494 Strategy::GzipEncode,
495 Strategy::DeflateEncode,
496 Strategy::TagCharEncode,
497 Strategy::VariationSelectorPad,
498 Strategy::VariationSelectorSupplementaryPad,
499 Strategy::LigatureEncode,
500 Strategy::CircledLetterEncode,
501 Strategy::ParenthesizedLetterEncode,
502 Strategy::SoftHyphenInject,
503 Strategy::WordJoinerWrap,
504 ];
505 strategies.sort_by(|a, b| {
506 super::layered::aggressiveness(*a)
507 .partial_cmp(&super::layered::aggressiveness(*b))
508 .unwrap_or(std::cmp::Ordering::Equal)
509 });
510 strategies
511});
512
513#[must_use]
514pub fn all_strategies() -> &'static [Strategy] {
515 &ALL_STRATEGIES
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn encode_url_encode_basic() {
524 assert_eq!(encode("A<", Strategy::UrlEncode).unwrap(), "A%3C");
525 }
526
527 #[test]
528 fn encode_url_encode_lower() {
529 assert_eq!(encode("A<", Strategy::UrlEncodeLower).unwrap(), "A%3c");
530 }
531
532 #[test]
533 fn encode_double_url_encode() {
534 assert_eq!(
535 encode("A<", Strategy::DoubleUrlEncode).unwrap(),
536 "%2541%253C"
537 );
538 }
539
540 #[test]
541 fn encode_case_alternation() {
542 let result = encode("SELECT", Strategy::CaseAlternation).unwrap();
543 assert!(result.contains("SeL") || result.contains("sEl"));
544 }
545
546 #[test]
547 fn encode_null_byte() {
548 let result = encode("file.php", Strategy::NullByte).unwrap();
549 assert!(result.contains('\x00') || result.contains("%00"));
550 }
551
552 #[test]
553 fn encode_base64() {
554 assert_eq!(encode("hello", Strategy::Base64Encode).unwrap(), "aGVsbG8=");
555 }
556
557 #[test]
558 fn encode_hex() {
559 assert_eq!(encode("ABC", Strategy::HexEncode).unwrap(), "414243");
560 }
561
562 #[test]
563 fn encode_json() {
564 assert_eq!(encode("A<", Strategy::JsonEncode).unwrap(), "A<");
568 assert_eq!(encode("a\\\nb", Strategy::JsonEncode).unwrap(), "a\\\\\\nb");
570 }
571
572 #[test]
573 fn encode_html_entity() {
574 assert_eq!(
575 encode("A<", Strategy::HtmlEntityEncode).unwrap(),
576 "A<"
577 );
578 }
579
580 #[test]
581 fn encode_invalid_utf8_fails() {
582 let invalid = vec![0x80, 0x81, 0x82];
583 let result = encode(&invalid, Strategy::CaseAlternation);
584 assert!(matches!(result, Err(EncodeError::InvalidUtf8)));
585 }
586
587 #[test]
588 fn encode_payload_too_large_fails() {
589 let huge = vec![b'X'; MAX_PAYLOAD_SIZE + 1];
590 let result = encode(&huge, Strategy::UrlEncode);
591 assert!(matches!(result, Err(EncodeError::PayloadTooLarge { .. })));
592 }
593
594 #[test]
601 fn encode_at_exact_max_payload_size_succeeds() {
602 let at_limit = vec![b'X'; MAX_PAYLOAD_SIZE];
603 let result = encode(&at_limit, Strategy::UrlEncode);
604 assert!(
605 result.is_ok(),
606 "boundary contract: exactly MAX_PAYLOAD_SIZE bytes must encode, got {result:?}"
607 );
608 }
609
610 #[test]
611 fn all_strategies_non_empty() {
612 let strategies = all_strategies();
613 assert!(!strategies.is_empty());
614 assert!(strategies.contains(&Strategy::UrlEncode));
615 }
616
617 #[test]
618 fn strategy_as_str_roundtrip() {
619 for s in all_strategies() {
620 assert!(!s.as_str().is_empty());
621 }
622 }
623
624 #[test]
625 fn strategy_contexts_returns_slice() {
626 assert!(Strategy::UrlEncode.contexts().is_empty());
627 assert_eq!(Strategy::JsonEncode.contexts(), &["json"]);
628 assert_eq!(Strategy::SpaceToComment.contexts(), &["sql"]);
629 }
630
631 #[test]
632 fn encode_empty_payload() {
633 assert_eq!(encode("", Strategy::UrlEncode).unwrap(), "");
634 }
635
636 #[test]
637 fn encode_unicode() {
638 let result = encode("A<", Strategy::UnicodeEncode).unwrap();
639 assert!(result.contains("\\u"));
640 }
641
642 #[test]
643 fn encode_chunked_split() {
644 let result = encode("hello", Strategy::ChunkedSplit).unwrap();
645 assert!(result.contains("\r\n"));
646 assert!(result.ends_with("0\r\n\r\n"));
647 }
648
649 #[test]
650 fn encode_parameter_pollution() {
651 let result = encode("key=value", Strategy::ParameterPollution).unwrap();
652 assert!(result.contains("key="));
653 }
654
655 #[test]
656 fn encode_gzip_produces_base64() {
657 let result = encode("hello", Strategy::GzipEncode).unwrap();
658 assert!(!result.is_empty());
660 }
661
662 #[test]
663 fn encode_iis_unicode() {
664 let result = encode("A<", Strategy::IisUnicodeEncode).unwrap();
665 assert!(result.contains("%u"));
666 }
667}