2010-10-20:

PHP preg_match i UTF-8

php:easy:utf-8
Kilka dni temu dostałem kawałek kodu napisanego w PHP 5 oraz pytanie "czy to jest OK?". Kod zajmował się walidacją pól formularza (tj. tzw user input), a konkretniej, sprawdzał czy user input składa się tylko z liter, łacińskich (A-Z) oraz polskich liter diakrytyzowanych (czyli Ą Ż Ś Ź Ę Ć Ń Ó Ł oraz małych wersji tychże liter: ą ż ś ź ę ć ń ó ł). Dodatkowo, weryfikacji podlegała ilość wprowadzonych liter - limit był stosunkowo niewielki. Jak zapewne się już domyśliliście, kod był "nie OK", a błąd był na tyle ciekawy, iż zdecydowałem się go opisać.

Istotne informacje zanim zaczniemy: Kod miał docelowo działać pod PHP 5, a zarówno strona jak i sam plik z źródłem były kodowane w UTF-8.

Samo sprawdzanie wyglądało tak (w wersji uproszczonej, tj. zmieniłem limit znaków do 5, oraz ograniczyłem regexp tak aby oczekiwał dokładnie jednego słowa):
preg_match('/^[a-zA-Zążśźęćń󳥯ŚŹĘĆŃÓŁ]{1,5}$/', $input_text)
Moim zdaniem intencja autora kodu jest jasna:
- tylko polskie i łacińskie litery są dozwolone,
- minimalna ilość oczekiwanych liter to 1, a maksymalna dopuszczona to 5

Na pierwszy rzut oka wszystko wygląda OK, ale niestety... preg_match nie działa domyślnie w trybie UTF-8, więc nie wie, że niektóre z podanych liter mogą zajmować więcej niż jeden bajt.
Rzućmy okiem jak różne polskie litery diakrytyczne są kodowane w UTF-8:
ą: C4 85    ż: C5 BC    ś: C5 9B    ź: C5 BA
ę: C4 99    ć: C4 87    ń: C5 84    ó: C3 B3
ł: C5 82    Ą: C4 84    Ż: C5 BB    Ś: C5 9A
Ź: C5 B9    Ę: C4 98    Ć: C4 86    Ń: C5 83
Ó: C3 93    Ł: C5 81

Taak, każda z nich zajmuje dokładnie dwa bajty. A jak pisałem, preg_match o tym nie wie, więc oczywistą konsekwencją tego jest fakt, że regexp /[Ł]/ zostanie potraktowany jako /[\xC5\x81]/, czyli zarówno bajt (znak) \xC5 jak i \x81 są zgodne z niniejszym regexpem, nawet jeśli nie są podane razem!

Podsumujmy co nie zadziała zgodnie z myślą autora kodu:
1. Skoro możemy (oprócz liter łacińskich) korzystać ze znaków: 81 82 83 84 85 86 87 93 98 99 9A 9B B3 B9 BA BB BC C4 C5 (posortowane względem wartości), to każda dowolna kombinacja tychże bajtów przejdzie przez filtry (z dokładnością do długości kombinacji oczywiście).
Czyli, możemy albo podać jako input sekwencje bajtów które nie stanowią prawidłowych znaków UTF-8, np. można zacząć od 80-BF, czyli przedziału zarezerwowanego na drugi, trzeci lub czwarty bajt poprawnej sekwencji UTF-8 (a nie pierwszy), albo złożyć sekwencje składającą się tylko z bajtów C4 lub C5, które są zarezerwowane jako bajty rozpoczynające sekwencje. Może nam się poszczęści i dostaniemy nawet jakiś warning/error bazy danych (a tym samym trochę info/path disclosure)? Kto wie :)
Lub... możemy też jako input podać prawidłowe znaki UTF-8, rozpoczynające się od C4 lub C5, w których drugim i ostatnim bajtem będzie jakiś inny bajt z wyżej wymienionej listy (poza C4 i C5 ofc). Stosując różne poprawne kombinacje dozwolonych wartości, możemy otrzymać jeden z poniższy znaków:
grupa C4:
ā: C4 81    Ă: C4 82    ă: C4 83    Ą: C4 84
ą: C4 85    Ć: C4 86    ć: C4 87    ē: C4 93
Ę: C4 98    ę: C4 99    Ě: C4 9A    ě: C4 9B
ij: C4 B3    Ĺ: C4 B9    ĺ: C4 BA    Ļ: C4 BB
ļ: C4 BC
grupa C5:
Ł: C5 81    ł: C5 82    Ń: C5 83    ń: C5 84
Ņ: C5 85    ņ: C5 86    Ň: C5 87    œ: C5 93
Ř: C5 98    ř: C5 99    Ś: C5 9A    ś: C5 9B
ų: C5 B3    Ź: C5 B9    ź: C5 BA    Ż: C5 BB
ż: C5 BC

2. Drugą rzeczą która nie pójdzie po myśli autora, jest limit ilości znaków. Autor chciał, aby górny limit wynosił 5 liter, niestety, wynosi on 5 bajtów. Czyli np. 5-cio literowy string ŁŁŁŁŁ nie przejdzie, ponieważ ma 10 bajtów (5 * 2 = 10 >). Również dolny limit się nie sprawdzi, ponieważ można podać np. samo C5 (1 bajt), czyli "pół litery".

Więc... jak to naprawić?
1. Użyć jakiejś funkcji z zestawu mb_*, które rozumieją UTF-8, np. mb_ereg (należy pamiętać aby ustawić mb_regex_encoding lub mb_internal_encoding na UTF-8!).
2. Wygląda na to, że dodanie na początku regexpa stringu (*UTF8) również załatwia sprawę (w tym wypadku powinno być preg_match('/(*UTF8)^[a-zA-Zążśźęćń󳥯ŚŹĘĆŃÓŁ]{1,5}$/', $input_text)), jednak z tego co słyszałem, to czy to działa czy nie, zależy od tego jak PHP był skompilowany (nie znam niestety szczegółów), więc może to nie ruszyć na niektórych hostingach, lub np. na naszym localhoscie.
3. Jak podpowiada komentarz po angielskiej stronie lustra, dodanie u na koniec regexpa również rozwiąże problem (preg_match('/^[a-zA-Zążśźęćń󳥯ŚŹĘĆŃÓŁ]{1,5}$/u', $input_text)). Natomiast nie testowałem czy działa to wszędzie (patrz uwagi punkt wyżej).
4. PHP 6 ma być natywnie wspierać unicode... ale to jeszcze trochę :)

OK, to tyle na dziś.

P.S. testy (i kod testów) które robiłem do tego postu można znaleźć tutaj
Tests
Tests (source code)

P.S.2. ale wiecie że strlen('string w utf-8') zwraca ilość bajtów a nie liter, tak? tak??? właśnie dlatego powstała funkcja mb_strlen() :)

Comments:

2010-10-20 18:56:38 = Tomasz Kowalczyk
{
PHP jest [niestety] znany z tego problemu jakim jest obsługa wielobajtowych ciągów znaków. Biblioteka standardowa tutaj wiele nie pomaga, bo najpierw trzeba znać narzędzia takie jak funkcje mb_* czy iconv(), ale i one nie są idealne. Miałem kilka tygodni temu problem, którego nie chciał ruszyć żadne mechanizm wbudowany w którąkolwiek bibliotekę [to było coś w stylu ciągów Latin1 zapisanych w UTF-8, które następnie były czytane jako jeszcze inne kodowanie, w ogóle masakra, a ja miałem to doprowadzić do działania] - dopiero manualna konwersja każdego "krzaka" sedem dała zadowalający efekt.

Pozostaje tylko powiedzieć - "ech"...
}
2010-10-20 23:27:11 = garbaty lamer
{
PHP jest znany z ogromnej ilości problemów, niedoróbek, dziur bezpieczeństwa i innych niedociągnięć. Lecz bez niego Web nie byłby tam, gdzie obecnie jest. Dalej mielibyśmy CGI i Gophera na topie, 2 miliony zamiast 2 miliardów użytkowników, a na stronach byłoby dziesiątki małych animowanych GIFów jak tu: http://speckyboy.com/2008/10/05/elements-of-ugly-web-design-navigation-animated-gifs/ ;-)
}
2010-10-21 16:18:24 = johny
{
A czy istnieje jakaś deklaracja (na wzór setlocale() dotyczącej np. czasu czy formatowania liczb), która spowodowałaby, że to:

echo mb_strlen('ŚŻŹĘĄ');

zwróci "5", bez pisania:

echo mb_strlen('ŚŻŹĘĄ', 'utf-8');


Bo gdy tworzymy cała stronę w UTF-8 to trochę uciążliwe jest pamiętanie o tym i pisanie tego za każdym razem, dla różnych funkcji.
}
2010-10-22 02:07:58 = Gynvael Coldwind
{
@Tomasz Kowalczyk
Hehe tyaa, też mi się kilka razy zdarzył przypadek (nie tylko w PHP) że bez "manualnej" konwersji się nie obeszło. Bywa :)

@garbaty lamer
Ciekawe czy to zasługa strikte PHP. Owszem, PHP u nas jest bardzo popularny, ale obiło mi się o uszy, że nie wszędzie tak jest. A trzeba pamiętać, że oprócz PHP jest jeszcze dość podobny ASP (czy nowszy ASP.NET), jak i np. JSP.
Przyznaję, że chętnie bym rzucił okiem na statystyki popularności PHP i innych podobnych technologii wg. kraju np. w roku 2k1, 2k3, 2k5, etc :)

@johny
Zachęcam do uważnego przeczytania propozycji poprawienia kodu w powyższym poście (od linijki "Więc... jak to naprawić?"), szczególnie drugą część pierwszego podpunktu :)
}
2010-10-24 22:03:17 = garbaty lamer
{
@gyn

muszę wytoczyć najcięższe działo: facebook.com. Ja osobiście preferuję ASP.NET, jednak bez PHP IMO wiele serwisów internetowych by nie powstało ;-) Ciekawe może być spekulowanie, na jakiej technologii serwerowej oparty byłby Web, gdyby nie powstało PHP?
}
2010-10-26 11:02:11 = Gynvael Coldwind
{
@garbaty_lamer
Hehe to dobre pytanie ;)
Cóż, przypuszczam, że gdy tylko będzie możliwa podróż między alternatywnymi wszechświatami to będziemy mogli sprawdzić*

* o ile oczywiście znajdziemy alternatywny wszechświat w którym PHP nie powstało ;D
}
2011-08-14 03:04:18 = Kamil
{
Ad. 3) Flaga u działa wszędzie.
Ad. 4) Raczej się tego nie doczekamy :D
}
2011-09-24 23:29:41 = Rafał G.
{
@Kamil - "Flaga u działa wszędzie."
Niestety nie. Próbowałem to zmusić do działania na swoim testowym Ubuntu Serverze i nie udało się. Ustawiłem oczywiście locale na polskie.

Modyfikator "u" jest wymieniony w manualu PHP jako dostępny od wersji 4.1, ale jest też (w jednym z komentarzy) informacja, że PCRE musi być kompilowane do używania UTF-8, a najwyraźniej nie zawsze jest.

Nie wiem w zasadzie jak Ubuntu Server (10.04) ma się do produkcyjnych serwerów, ale wersja PHP to 5.3.2, więc nie taka stara.

Co do flagi /u i sekwencji (*UTF8) - wydaje mi się, że one powinny mieć się do siebie tak jak np. flaga /i do sekwencji (?i) - czyli oznaczać dokładnie to samo. Jeśli tak jest (nie udało mi się znaleźć na ten temat informacji) to gdy jedno nie działa, drugie też nie będzie.

Ogólnie panuje tu niezły burdel i nie ma łatwo. Problem z posta można rozwiązać przez rozłożenie walidacji na dwie części - sprawdzenie długości i sprawdzenie, czy zawiera tylko polskie znaki. Co jeśli robi się bardziej skomplikowanie i musimy np. wyłuskać wszystkie słowa o długości x-y znaków, a flagi nie działają? Wtedy trzeba kombinować i zostaje chyba tylko coś w stylu:

preg_match_all( '/(?<![\wżółćęśąźń])(?:\w|ż|ó|ł|ć|ę|ś|ą|ź|ń){5,6}(?![\wżółćęśąźń])/i', 'Zażółć gęślą jaźń. Mężny bądź, chroń pułk twój i sześć flag.', $m );
print_r( $m );

Bardzo chętnie poczytam o lepszych rozwiązaniach :)
}
2014-12-16 16:40:06 = Camil
{
Witam,

mam takie pytanie.
Napisales ó: C3 B3 i Ó: C3 93
ale potem wogule zapomniales o grupie C3 albo dlaczego brakuje Ó: C3 93 i ó: C3 B3

pozdrawia
Camil
}
2014-12-16 16:58:30 = Gynvael Coldwind
{
@Camil
Well spotted! Oczywiście powinna być jeszcze grupa C3 tam wymieniona (natomiast na szczęście nie zmienia to sensu przeszłania) :)
}

Add a comment:

Nick:
URL (optional):
Math captcha: 2 ∗ 7 + 6 =