Skip to content

Commit beff021

Browse files
committed
idn: hand libidn2 the same flags on both sides
The client path called idn2_lookup_ul() without IDN2_NFC_INPUT while the daemon path passed it to idn2_to_ascii_8z(). Both normalize either way -- idn2_lookup_ul() ors the flag in itself, and TR46 normalizes as it maps -- but there is no reason for the two calls to read differently, so pass one set of flags from one place. The flag asks libidn2 to normalize the label rather than promising that it already is: it gates the u32_normalize() call, and without it a decomposed label comes back IDN2_NOT_NFC. Adds composed/decomposed cases to testsuite/idn, which sees the exact host name rsync hands out, and a decomposed hosts allow token to testsuite/daemon-access-idn.
1 parent f956fae commit beff021

5 files changed

Lines changed: 34 additions & 12 deletions

File tree

‎rsync.1.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,10 @@ If rsync was built with IDN support (look for "IDN" in `rsync --version`), the
416416
daemon host may contain non-ASCII characters: those labels are converted to
417417
their IDNA A-label ("Punycode") form before the name is looked up. The name is
418418
read using your locale's character encoding, so be sure your locale is set
419-
correctly. Only the non-ASCII labels change, so an address literal, a name you
420-
punycoded yourself, and a name that is not a valid IDN are all looked up just
421-
as you typed them.
419+
correctly. A name typed with combining characters is normalized on the way, so
420+
it is looked up the same as its precomposed spelling. Only the non-ASCII
421+
labels change, so an address literal, a name you punycoded yourself, and a name
422+
that is not a valid IDN are all looked up just as you typed them.
422423

423424
This applies to the host of a daemon connection only. The host of a plain
424425
remote-shell transfer (the single-colon syntax) is passed to your remote-shell

‎rsyncd.conf.5.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -857,11 +857,13 @@ in the values of parameters. See that section for details.
857857
a hostname pattern may contain non-ASCII characters: this file is read as
858858
UTF-8, and each non-ASCII label is converted to its IDNA A-label
859859
("Punycode") form before matching, since the name the daemon has for a
860-
client always reaches it from DNS as ASCII. Only those labels change, so
861-
an address, a mask, an already-punycoded name, and the wildcard characters
862-
are all matched exactly as written. A pattern that cannot be converted
863-
that way is matched as it stands, and thus matches nothing. That includes
864-
a pattern whose conversion would have introduced a character it was not
860+
client always reaches it from DNS as ASCII. A pattern written with
861+
combining characters is normalized on the way, so it matches the same as
862+
its precomposed spelling. Only those labels change, so an address, a
863+
mask, an already-punycoded name, and the wildcard characters are all
864+
matched exactly as written. A pattern that cannot be converted that way
865+
is matched as it stands, and thus matches nothing. That includes a
866+
pattern whose conversion would have introduced a character it was not
865867
written with, such as the U+FF0A FULLWIDTH ASTERISK that the IDNA mapping
866868
turns into a "*".
867869

‎testsuite/daemon-access-idn_test.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ def fullwidth(name):
114114
# stay denied, and neither may be mistaken for a wildcard.
115115
('idn-other', {'hosts allow': 'čičku.example'}),
116116
('idn-puny', {'hosts allow': 'xn--iku-eqab.example'}),
117+
# idn-other's token respelled with combining carons: an equivalent name
118+
# under Unicode, so it has to be treated the same way. (A decomposed
119+
# token that *matches* isn't constructible here -- the peer name comes
120+
# from DNS and is ASCII -- so idn_test checks the two spellings convert
121+
# alike, and this checks the daemon agrees they don't match.)
122+
('idn-nfd', {'hosts allow': 'c\u030ci' 'c\u030cku.example'}),
117123
# Tokens whose IDNA mapping yields ASCII the author never wrote. Left
118124
# unconverted they match nothing; converted blindly, the first two would
119125
# allow every host and the third would read as an address/mask.
@@ -154,6 +160,7 @@ def denied(mod, why):
154160
denied('idn-deny', "hosts deny sees the folded token too")
155161
denied('idn-other', "a different IDN must not match the peer")
156162
denied('idn-puny', "an A-label for a different host must not match the peer")
163+
denied('idn-nfd', "a decomposed spelling of that name must not match either")
157164
denied('wide-star', "U+FF0A must not become a '*' that allows every host")
158165
denied('wide-star-dom', "U+FF0A must not become a wildcard label")
159166
denied('wide-mask', "U+FF0F must not become an address/mask separator")

‎testsuite/idn_test.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# its IDNA A-label (Punycode) form, and that it leaves an ASCII host name
44
# alone. Only the labels that are not ASCII get rewritten, so an address
55
# literal, an already-punycoded name, and a name that isn't a valid IDN all
6-
# reach the resolver as typed.
6+
# reach the resolver as typed. A name typed with combining marks is normalized
7+
# on the way, so it converts the same as its precomposed spelling.
78
#
89
# Two daemon connection methods carry the host name out of rsync, so both are
910
# checked:
@@ -55,6 +56,10 @@ def find_utf8_locale():
5556

5657
idn_host = "\u010ci\u010dku.example"
5758
ascii_host = "xn--iku-eqab.example"
59+
# The same name with each caron letter spelled as a plain "c" plus a combining
60+
# caron (U+030C). Unicode calls the two spellings equivalent, so both have to
61+
# come out as the same A-label; libidn2 is what normalizes them.
62+
nfd_host = "c\u030ci" "c\u030cku.example"
5863

5964
env = os.environ.copy()
6065
env['LC_ALL'] = utf8_locale
@@ -103,6 +108,9 @@ def check_rsh(url_host, want, what):
103108
# A U-label becomes its A-label, case-folded by the IDNA mapping. An ASCII
104109
# label is handed on byte for byte, case included, since DNS doesn't care.
105110
check_rsh(idn_host, ascii_host, "a Unicode host")
111+
check_rsh(nfd_host, ascii_host, "a decomposed Unicode host")
112+
check_rsh("C\u030cI" "C\u030cKU.Example", "xn--iku-eqab.Example",
113+
"a decomposed mixed-case Unicode host")
106114
check_rsh("ČIČKU.Example", "xn--iku-eqab.Example",
107115
"a mixed-case Unicode host")
108116
check_rsh(ascii_host, ascii_host, "an already-punycoded host")

‎util1.c‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -986,14 +986,18 @@ int idn_to_ascii(const char *name, int from_locale, char *buf, size_t buflen)
986986
memcpy(buf + len, lab, lablen);
987987
len += lablen;
988988
} else {
989+
/* IDN2_NFC_INPUT has libidn2 normalize the label, so a name
990+
* typed with combining marks folds to the same A-label as
991+
* its composed spelling. IDN2_NONTRANSITIONAL asks for the
992+
* TR46 processing that everything else does these days. */
993+
int flags = IDN2_NFC_INPUT | IDN2_NONTRANSITIONAL;
989994
int rc;
990995
if (lablen >= sizeof label)
991996
return 0;
992997
memcpy(label, lab, lablen);
993998
label[lablen] = '\0';
994-
rc = from_locale
995-
? idn2_lookup_ul(label, &idn, IDN2_NONTRANSITIONAL)
996-
: idn2_to_ascii_8z(label, &idn, IDN2_NFC_INPUT | IDN2_NONTRANSITIONAL);
999+
rc = from_locale ? idn2_lookup_ul(label, &idn, flags)
1000+
: idn2_to_ascii_8z(label, &idn, flags);
9971001
if (rc != IDN2_OK)
9981002
return 0;
9991003
alen = strlen(idn);

0 commit comments

Comments
 (0)