Decode mixed UTF-16 clipboard text (#7466)
* Decode mixed UTF-16 clipboard text * Harden UTF-16 clipboard detection * Finish UTF-16 decoder hardening
This commit is contained in:
@@ -43,22 +43,47 @@ emit_image() {
|
||||
}
|
||||
|
||||
emit_text() {
|
||||
perl -MEncode=decode,FB_CROAK -MJSON::PP=encode_json -0777 -e '
|
||||
perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e '
|
||||
my $raw = <STDIN>;
|
||||
exit unless length $raw;
|
||||
|
||||
my $encoding;
|
||||
my $heuristic_encoding = 0;
|
||||
if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) {
|
||||
$encoding = "UTF-16";
|
||||
} elsif ($raw =~ /^(?:[^\0]\0)+\z/s) {
|
||||
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes, so only
|
||||
# decode the consistent whole-payload pattern seen from affected apps.
|
||||
$encoding = "UTF-16LE";
|
||||
} elsif ($raw =~ /^(?:\0[^\0])+\z/s) {
|
||||
$encoding = "UTF-16BE";
|
||||
} elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) {
|
||||
my $units = length($raw) / 2;
|
||||
my $nuls = $raw =~ tr/\0/\0/;
|
||||
|
||||
# Neither byte lane can reach the padding threshold when the entire
|
||||
# payload contains fewer NULs than that, so avoid two full string passes.
|
||||
if ($nuls * 4 >= $units * 3) {
|
||||
my $even_bytes = $raw;
|
||||
$even_bytes =~ s/(.)./$1/sg;
|
||||
my $even_nuls = $even_bytes =~ tr/\0/\0/;
|
||||
undef $even_bytes;
|
||||
|
||||
my $odd_bytes = $raw;
|
||||
$odd_bytes =~ s/.(.)/$1/sg;
|
||||
my $odd_nuls = $odd_bytes =~ tr/\0/\0/;
|
||||
|
||||
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode
|
||||
# only when at least three quarters of the code units have consistent
|
||||
# padding and fewer than one quarter have NULs in the opposite byte.
|
||||
if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) {
|
||||
$encoding = "UTF-16LE";
|
||||
$heuristic_encoding = 1;
|
||||
} elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) {
|
||||
$encoding = "UTF-16BE";
|
||||
$heuristic_encoding = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK) } : undef;
|
||||
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef;
|
||||
if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) {
|
||||
$text = undef;
|
||||
}
|
||||
$text = decode("UTF-8", $raw) unless defined $text;
|
||||
print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n";
|
||||
'
|
||||
|
||||
@@ -296,8 +296,36 @@ capture_output=$(printf '%s' 'UTF-16 clipboard - fixed' | iconv -f UTF-8 -t UTF-
|
||||
[[ $capture_output == '{"type":"text","text":"UTF-16 clipboard - fixed"}' ]] || fail "clipboard capture decodes UTF-16LE text"
|
||||
pass "clipboard capture decodes UTF-16LE text"
|
||||
|
||||
# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The strict
|
||||
# whole-payload pattern intentionally resolves that ambiguity as UTF-16.
|
||||
capture_output=$(printf '%s' 'https://example.com/image — preview …' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"https://example.com/image — preview …"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation"
|
||||
pass "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation"
|
||||
|
||||
capture_output=$(printf '%s' 'Text with 日本 and 😀' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"Text with 日本 and 😀"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters"
|
||||
pass "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters"
|
||||
|
||||
capture_output=$(printf '%s' 'ABC—' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"ABC—"}' ]] || fail "clipboard capture decodes UTF-16LE text at the padding threshold"
|
||||
pass "clipboard capture decodes UTF-16LE text at the padding threshold"
|
||||
|
||||
printf '%s' 'ABCDEFGH————' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/below-utf16-threshold"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/below-utf16-threshold")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/below-utf16-threshold")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text below the padding threshold undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves UTF-16LE text below the padding threshold undecoded"
|
||||
|
||||
printf '%s' 'ABCĀ' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/opposite-nul-threshold"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/opposite-nul-threshold")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/opposite-nul-threshold")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded"
|
||||
|
||||
capture_output=$(printf 'a\fb' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"a\fb"}' ]] || fail "clipboard capture preserves UTF-16LE form feeds"
|
||||
pass "clipboard capture preserves UTF-16LE form feeds"
|
||||
|
||||
# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The padding
|
||||
# heuristic intentionally resolves that ambiguity as UTF-16.
|
||||
capture_output=$(printf 'A' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"A"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16LE text"
|
||||
pass "clipboard capture decodes exact NUL-padded UTF-16LE text"
|
||||
@@ -327,6 +355,24 @@ assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 punc
|
||||
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 CJK undecoded" '日本'
|
||||
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 surrogate pairs undecoded" '😀'
|
||||
|
||||
printf 'foo\0bar\0' >"$TMPDIR/nul-separated-utf8"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-separated-utf8")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-separated-utf8")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves sparse NUL-separated UTF-8 undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves sparse NUL-separated UTF-8 undecoded"
|
||||
|
||||
printf 'Hello\0\0\0\0\0\0\0\0\0\0\0' >"$TMPDIR/nul-padded-utf8"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-padded-utf8")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-padded-utf8")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves NUL-padded UTF-8 undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves NUL-padded UTF-8 undecoded"
|
||||
|
||||
printf '\001\000\001\000\001\000\001\000' >"$TMPDIR/ambiguous-control-text"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-control-text")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-control-text")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves endian-ambiguous control text undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves endian-ambiguous control text undecoded"
|
||||
|
||||
printf '\377\376\075\330' >"$TMPDIR/malformed-utf16"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/malformed-utf16")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/malformed-utf16")
|
||||
|
||||
Reference in New Issue
Block a user