|
31 | 31 | # does not rely on nonce secrecy: the phrase is neutralized regardless of |
32 | 32 | # whether the attacker guesses or observes the real nonce. |
33 | 33 | # |
| 34 | +# The whitespace-tolerant marker match above is ASCII-only ([[:space:]]), |
| 35 | +# so before it runs we also fold Unicode whitespace to ASCII space and drop |
| 36 | +# invisible/zero-width characters (see step 2 below) — otherwise a Unicode |
| 37 | +# space or zero-width char between the marker words (e.g. "UNTRUSTED<NBSP> |
| 38 | +# USER CONTENT", which renders identically to a plain space to a downstream |
| 39 | +# LLM/markdown consumer) would survive un-neutralized and forge a |
| 40 | +# visually-identical closing fence. |
| 41 | +# |
34 | 42 | # MECHANICAL SANITIZATION applied to the body, in this order: |
35 | 43 | # 1. Strip ANSI escape sequences, then any remaining control characters |
36 | 44 | # other than tab (\t) and newline (\n). |
37 | | -# 2. Neutralize the literal fence marker phrase (anti-spoof, see above). |
38 | | -# 3. Escape angle brackets (< / >) to HTML entities so |
| 45 | +# 2. Normalize Unicode whitespace/invisible characters: fold common |
| 46 | +# Unicode space separators (Zs category, e.g. NBSP, en/em space, |
| 47 | +# ideographic space) to an ASCII space, and remove zero-width/invisible |
| 48 | +# characters (zero-width space/joiners, word joiner, BOM) so they |
| 49 | +# can't be used to invisibly split or reconstruct the marker phrase. |
| 50 | +# 3. Neutralize the literal fence marker phrase (anti-spoof, see above). |
| 51 | +# Because step 2 already folded Unicode spaces to ASCII space and |
| 52 | +# removed invisible chars, the existing whitespace-tolerant match here |
| 53 | +# catches Unicode-space/zero-width variants automatically. |
| 54 | +# 4. Escape angle brackets (< / >) to HTML entities so |
39 | 55 | # <script>/<!-- -->/<img onerror=...> etc. can't inject markup. |
40 | | -# 4. Defang @mentions by inserting a space right after the @, so they |
| 56 | +# 5. Defang @mentions by inserting a space right after the @, so they |
41 | 57 | # can't ping/act as GitHub mentions. |
42 | | -# 5. Neutralize issue-closing autolink keywords ("fixes/closes/resolves |
| 58 | +# 6. Neutralize issue-closing autolink keywords ("fixes/closes/resolves |
43 | 59 | # #N", any tense, case-insensitive) so the body can't auto-close an |
44 | 60 | # issue when posted as a comment. |
45 | | -# 6. Enforce a length cap (SANITIZE_MAX_CHARS, default 8000 chars): |
| 61 | +# 7. Enforce a length cap (SANITIZE_MAX_CHARS, default 8000 chars): |
46 | 62 | # truncate and append "…[truncated N chars]". |
47 | 63 | # |
48 | 64 | # Pure bash + coreutils (sed/tr/printf) only — no gh, no network — so it |
@@ -79,27 +95,52 @@ body="$(printf '%s' "$raw" \ |
79 | 95 | | sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g' \ |
80 | 96 | | tr -d '\000-\010\013-\037\177')" |
81 | 97 |
|
82 | | -# 2. Anti-spoof: neutralize the marker phrase wherever it occurs in the body |
| 98 | +# 2. Normalize Unicode whitespace/invisible characters (byte-level, LC_ALL=C |
| 99 | +# so multibyte UTF-8 sequences match by raw bytes regardless of locale — |
| 100 | +# this must work identically under C/C.UTF-8/POSIX). This runs BEFORE |
| 101 | +# the ASCII-only [[:space:]] marker-phrase match below, so a Unicode |
| 102 | +# space or zero-width char between the marker words can't slip past it. |
| 103 | +# |
| 104 | +# 2a. Fold Unicode space separators (Zs category) to a plain ASCII |
| 105 | +# space: U+00A0 NBSP (C2 A0), U+2000-U+200A en/em/thin/hair/etc. |
| 106 | +# spaces (E2 80 80..8A), U+202F NARROW NBSP (E2 80 AF), U+205F |
| 107 | +# MEDIUM MATHEMATICAL SPACE (E2 81 9F), U+3000 IDEOGRAPHIC SPACE |
| 108 | +# (E3 80 80). |
| 109 | +unicode_space_pattern="$(printf '\xc2\xa0|\xe2\x80\x80|\xe2\x80\x81|\xe2\x80\x82|\xe2\x80\x83|\xe2\x80\x84|\xe2\x80\x85|\xe2\x80\x86|\xe2\x80\x87|\xe2\x80\x88|\xe2\x80\x89|\xe2\x80\x8a|\xe2\x80\xaf|\xe2\x81\x9f|\xe3\x80\x80')" |
| 110 | +body="$(printf '%s' "$body" \ |
| 111 | + | LC_ALL=C sed -E "s/${unicode_space_pattern}/ /g")" |
| 112 | + |
| 113 | +# 2b. Remove zero-width/invisible characters so they can't be used to |
| 114 | +# invisibly split a marker word: U+200B/U+200C/U+200D ZERO WIDTH |
| 115 | +# SPACE/NON-JOINER/JOINER (E2 80 8B..8D), U+2060 WORD JOINER |
| 116 | +# (E2 81 A0), U+FEFF BOM/ZERO WIDTH NO-BREAK SPACE (EF BB BF). |
| 117 | +invisible_char_pattern="$(printf '\xe2\x80\x8b|\xe2\x80\x8c|\xe2\x80\x8d|\xe2\x81\xa0|\xef\xbb\xbf')" |
| 118 | +body="$(printf '%s' "$body" \ |
| 119 | + | LC_ALL=C sed -E "s/${invisible_char_pattern}//g")" |
| 120 | + |
| 121 | +# 3. Anti-spoof: neutralize the marker phrase wherever it occurs in the body |
83 | 122 | # (case-insensitive, whitespace-tolerant between the words — matches a |
84 | 123 | # single space, a double space, a tab, or any run of whitespace, so |
85 | 124 | # whitespace-variant forged fences can't survive un-neutralized), so |
86 | 125 | # untrusted text can never contain a string identical to the real fence |
87 | | -# markers below. |
| 126 | +# markers below. Step 2 above already folded Unicode spaces to ASCII |
| 127 | +# space and removed invisible chars, so this ASCII [[:space:]] match |
| 128 | +# also catches Unicode-space/zero-width-split variants. |
88 | 129 | body="$(printf '%s' "$body" \ |
89 | 130 | | sed -E 's/untrusted[[:space:]]+user[[:space:]]+content/UNTRUSTED-USER-CONTENT(neutralized)/gI')" |
90 | 131 |
|
91 | | -# 3. Escape angle brackets so HTML/script/comment markup is inert. |
| 132 | +# 4. Escape angle brackets so HTML/script/comment markup is inert. |
92 | 133 | body="$(printf '%s' "$body" | sed -e 's/</\</g' -e 's/>/\>/g')" |
93 | 134 |
|
94 | | -# 4. Defang @mentions (insert a space right after @, before the handle). |
| 135 | +# 5. Defang @mentions (insert a space right after @, before the handle). |
95 | 136 | body="$(printf '%s' "$body" | sed -E 's/@([A-Za-z0-9_-])/@ \1/g')" |
96 | 137 |
|
97 | | -# 5. Neutralize issue-closing autolink keywords: "fixes/closes/resolves #N" |
| 138 | +# 6. Neutralize issue-closing autolink keywords: "fixes/closes/resolves #N" |
98 | 139 | # (any tense, case-insensitive) — break the "#N" so it can't autoclose. |
99 | 140 | body="$(printf '%s' "$body" | sed -E \ |
100 | 141 | 's/\b(closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve)([[:space:]]*)#([0-9]+)/\1\2# \3/gI')" |
101 | 142 |
|
102 | | -# 6. Length cap. |
| 143 | +# 7. Length cap. |
103 | 144 | total="${#body}" |
104 | 145 | if [ "$total" -gt "$max_chars" ]; then |
105 | 146 | removed=$(( total - max_chars )) |
|
0 commit comments