Dziś bardzo krótko, bez zbędnych dywagacji, czyli tylko i wyłącznie o tablicy $_SERVER. Dbając o bezpieczeństwo aplikacji webowych zwraca się uwagę na wiele czynników, jakimi są SQL injections, przechwytywanie nieprawidłowych parametrów, uogólniające zapytania przepuszczające maskę % w LIKE zapytaniu do baz, XSS‘y w $_POST, $_GET.
I finalnie… wiele osób zapomina (a jeszcze więcej nie jest tego świadom) o możliwości wstrzyknięcia szkodliwych danych w $_SERVER['HTTP_X_FORWARDED_FOR'];. Konsekwencje są oczywiście katastrofalne.
O ile sama walidacja jest rzeczą wtórną, diabeł tkwi w trzech szczegółach:
X-Forwarded-For).document.cookie i przesłać je sobie na serwer w dowolny sposób, zatem atakowi nie ulegną osoby trzecie.ip2long() i zapis w zupełności nieprzydatnych nam później danych do bazy.Przykład tampingu danych, żeby spreparować niepożądane efekty.
Mamy bardzo prosty, niebezpieczny kod funkcji, która pobiera pierwszy adres na liście adresów oddzielonych przecinkami z $_SERVER['HTTP_X_FORWARDED_FOR'] o ile istnieje, natomiast w przeciwnym wypadku $_SERVER['REMOTE_ADDR']:
< ?php function getUserIp() { if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])) return trim(current(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']))); return $_SERVER['REMOTE_ADDR']; } $sUserIP = getUserIp(); echo 'Hi "' . $sUserIP . '"!'; // first bug while display. var_dump(ip2long($sUserIP)); // second bug while transforming data. ?>
Pora na przykład manipulacji takich danych.
$_POST, $_GET, $_COOKIE, nagłówki, “w locie żądania” etc.X_FORWARDED_FOR=<script>alert('Test.')</script>Naszym oczom ukazują się co najmniej dwa błędy. Pierwszy to błąd prezentacji danych, który wykorzystuje <script>. O ile nie musimy się tym przejmować, bo naturalnie takie żądania nie są tak łatwo wysyłane, użytkownik nie może paść ofiarą ataku przez kliknięcie w link, który np. ukradnie mu ciasteczka. Dane nagłówkowe nie są w stanie być zmodyfikowane poprzez kliknięcie w link, podobnie jak z danymi $_POST (oczywiście mówimy o przypadkach trywialnych, bez javascript’owych wymuszanych submitów targetowanych do np. ramek).
Znacznie poważniejszym błędem jest konsekwencja wadliwego formatu danych, które nasza funkcja bagatelizuje. Po pierwsze mamy fałszywe dane zwracane przez ip2long(), po drugie kto powiedział, że właśnie z tej funkcji korzystamy, a nie zapisujemy danych plain’em i nie bindujemy pofiltrowanych danych lub instrukcji warunkowych zapytania przez np. sprawdzony ORM.
Rozwiązanie problemu.
Edit: Jak słusznie zauważył Zyx, zapomniałem o tym wspomnieć, że skoro mogą znaleźć się tam dowolne dane przesłane od użytkownika, nie należy tego pola traktować jako wyznacznik, że jest to numer jego IP, jest ono bezużyteczne i powoduje potencjalną lukę. Poza zabezpieczeniami to podstawowy argument, żeby o polu zapomnieć i używać $_SERVER['REMOTE_ADDR'].
Po pierwsze funkcja powinna sprawdzać dane wejściowe chociażby preg_match() lub konwersją do ip2long() i (jeżeli jest taka potrzeba) spowrotem do long2ip(). Dwa, pamiętajmy, że w X_FORWARDED_FOR znajdują się śmieci, adresy lokalne sieci, itd., które należy pominąć przy wyborze adresu z listy po przecinku.
We wpisie Chmura tagów w PHP, w którym został przedstawiony problem budowy chmury tagów zapisałem przykładowe zapytanie prezentujące przykładowe dane dla klasy, które dosłownie zabija bazę danych zliczając za każdym razem ilość występowań tagów. Dostając feedbacki, zauważyłem, że problem ten jest bagatelizowany przez wiele osób. Spróbujmy zbudować bardziej optymalne rozwiązanie zarządzania strukturą danych w taki sposób, aby dane wyciągać bardzo bezboleśnie.
Zbudujmy przykładową strukturę bazy danych tagów, do której będziemy przypinać różne rzeczy – newsy, artykuły, galerie zdjęć, zdjęcia, cokolwiek.
Najprostsza tabela db_tags o polach:
CREATE TABLE db_tags ( tag_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY , tag_name VARCHAR(255) NOT NULL , tag_count INT UNSIGNED NOT NULL ) ENGINE = INNODB;
Zastanówmy się, po czym będziemy sortować tagi. Warto założyć klucz na pole tag_count, znacznie przyspieszy późniejsze sortowanie wyników po najpopularniejszych tagach. Jeżeli chcemy sortować po liczbie występowań tagu oraz nazwie (aby chmura była alfabetycznie), warto założyć wspólny klucz na tag_name oraz tag_count. Osobiście sortowanie alfabetyczne zostawiam implementacji klasie tagów dla ksort(), bowiem zapytanie wyciągające tagi jest obarczone limitem, zatem wspólny klucz w bazie danych nie jest mi potrzebny – mniej danych w indeksach.
ALTER TABLE db_tags ADD INDEX (tag_count);
Tworzymy dowolną strukturę danych, która będzie podpinała się do naszych tagów. Pamiętajmy, że do tagów może podpinać się (a przynajmniej powinno, zależy od założeń początkowych projektu) wiele struktur jednocześnie. Wybrałem najbardziej pospolite – newsy w tabeli db_news.
CREATE TABLE db_news ( news_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, news_title TEXT NOT NULL, news_content TEXT NOT NULL ) ENGINE = INNODB;
Pozostało nam stworzyć tabelę wiążącą nasze newsy z tagami (nie tagi z newsami). Tabelę nazwałem db_news_tags. Zawierać ona będzie tylko dwa pola przechowujące identyfikator newsa oraz przypisanego do niego tagu, zachowując typ danych wiążących, czyli INT UNSIGNED. Zakładam wspólny primary key dla obu pól.
CREATE TABLE db_news_tags ( handler_item INT UNSIGNED NOT NULL, handler_node INT UNSIGNED NOT NULL, PRIMARY KEY (handler_item, handler_node) ) ENGINE = INNODB;
Buduję relacyjną bazę danych. Gdy jakiś tag zostanie usunięty, bądź gdy jakiś news zostanie usunięty, automatycznie powinien zniknąć wpis z tabeli db_news_tags, zatem używamy kluczy obcych:
ALTER TABLE db_news_tags ADD FOREIGN KEY (handler_item) REFERENCES db_news (news_id) ON DELETE CASCADE; ALTER TABLE db_news_tags ADD FOREIGN KEY (handler_node) REFERENCES db_tags (tag_id) ON DELETE CASCADE;
Tak zaprojektowaną strukturę danych mogę spokojnie używać do przechowywania danych. Pozostaje kwestia obliczania ilości występowań tagów. Istnieją co najmniej dwie szkoły.
Luźny komentarz techniczny (problems, tips & tricks): Aby ominąć problemy wynikłe z założenia w punkcie pierwszym, równie dobrze możemy napisać procedury, które inkrementują/dekrementują liczbę tagów w zależności od warunków (np. tylko wtedy, kiedy tag jest aktywny i widoczny w serwisie). Nikt nie powiedział, że procedury muszą liczyć wszystko od początku możemy się na takie rozwiązanie zgodzić, rezygnujemy natomiast z synchronizacji licznika podczas zmiany warunków, wówczas podczas każdej zmiany warunków, trzeba przekręcić licznik od początku, zliczając wszystkie rekordy wg. ustalonych warunków ręcznie. Triggera należałoby również umieścić w UPDATE (zmiana stanu tagu, np. z niewidocznego na widoczny, z aktywnego na nieaktywny). I to jest najrozsądniejsze rozwiązanie.
W naszym przypadku ograniczymy się do dwóch triggerów, które będą trzymały rękę na pulsie w momencie przypisania tagu do struktury INSERT oraz zerwaniu przypisania DELETE. Zatem:
CREATE TRIGGER NewsTagsCountInsert AFTER INSERT ON db_news_tags FOR EACH ROW BEGIN UPDATE db_tags SET tag_count = tag_count + 1 WHERE tag_id = NEW.handler_node; END CREATE TRIGGER NewsTagsCountDelete AFTER DELETE ON db_news_tags FOR EACH ROW BEGIN UPDATE db_tags SET tag_count = tag_count - 1 WHERE tag_id = OLD.handler_node; END
Komentarz: bardziej eleganckim w większej strukturze danych byłoby wywołanie procedur inkrementujących i dekrementujących licznik – wówczas wykonywalibyśmy procedury (nie zapytania) w wielu strukturach wiązanych (nie tylko newsy, a video, ankiety, etc). Zmiana implementacji liczenia tagów byłaby wówczas wiele prostsza – zmienialibyśmy tylko procedurę, a nie każdy TRIGGER z osobna, zatem:
CREATE PROCEDURE TagsCountIncrement(IN iTagID INT) BEGIN UPDATE db_tags SET tag_count = tag_count + 1 WHERE tag_id = iTagID; END CREATE PROCEDURE TagsCountDecrement(IN iTagID INT) BEGIN UPDATE db_tags SET tag_count = tag_count - 1 WHERE tag_id = iTagID; END CREATE TRIGGER NewsTagsCountInsert AFTER INSERT ON db_news_tags FOR EACH ROW BEGIN CALL TagsCountIncrement(NEW.handler_node); END CREATE TRIGGER NewsTagsCountDelete AFTER DELETE ON db_news_tags FOR EACH ROW BEGIN CALL TagsCountDecrement(OLD.handler_node); END
Finalnie, z czystym sumieniem:
SELECT tag_name, tag_count FROM db_tags ORDER BY tag_count LIMIT 0, 50
Składowanie danych w kilku tabelach połączonych relacyjnie to bardzo dobry pomysł. Chyba najprostszym przykładem jest forum dyskusyjne: struktura oraz content postów mogą spokojnie być trzymane w osobnych tabelach. To samo tyczy się danych użytkowników. Rekordy rozbite na kilka tabel stają się mniej rozbudowane, o ile w ogóle występują - istnienie zawartości pola nie jest wtedy wymagane (użytkownik nie podał danych = nie ma rekordu).
Zaznaczając JOIN‘ujemy tabele w zależności od potrzeb, co zdarza się bardzo często. O UPDATE JOIN już wspominałem, też bardzo wygodna operacja, natomiast, co w przypadku, gdy musimy usunąć rekord uzależniony od wartości pola w innej tabeli? Sprawa jest banalnie prosta.
Na początek kilka technicznych uwag, na które łatwo można się nadziać:
Przykłady.
Dane userów mam składowane w dwóch tabelach – w jednej podstawowe dane (id, name, pass, pass_salt, mail, status_active), w kolejnej dane (data [jako handler user -> user_data], data_* [* - jakieśdane]). Chcę usunąć wszystkich użytkowników, którzy zarejestrowali się przed 48-godzinami i nie aktywowali swoich kont, aby zwolnić unikalne nazwy użytkowników i adresy email. Jednym kryterium jest user_status_active z tabeli users, kolejnym jest data user_data_join z tabeli users_data. Jako, że mam założony kaskadowy foregin key na pole user_data w tabeli users_data, przy usunięciu rekordu z tabeli users pozbędę się również jego danych, o co dbać nie muszę przy wypisywaniu alias_tabeli.*. W przypadku, kiedy nie miałbym założonego foregin key, musiałbym obsłużyć usunięcie rekordu z users_data wypisując po przecinku tabelę. Zatem:
DELETE item.* FROM `cms_members` AS `item` INNER JOIN `cms_members_data` AS `item_data` ON (item.user_id = item_data.user_data) WHERE item.user_state_active = 0 AND item_data.user_data_join < NOW()
~Tiraeth przesłał rozwiązanie beż użycia aliasów i słowa kluczowego JOIN, odwołujemy się po nazwie tabeli:
DELETE cms_members.* FROM `cms_members`, `cms_members_data` WHERE cms_members.user_id = cms_members_data.user_data AND user_state_active = 0 AND user_data_join < NOW()
Podzapytanie oraz INNER JOIN generuje nam iloczyn kartezjański:
INNER JOIN and , (comma) are semantically equivalent in the absence of a join condition: both produce a Cartesian product between the specified tables (that is, each and every row in the first table is joined to each and every row in the second table).
Mam nadzieję, że komuś się przyda.
Oblicza MySQL nie są do końca znane przy tworzeniu aplikacji, a problemy optymalizacyjne stają się nie lada problemem przy funkcjonowaniu wersji produkcyjnej projektu. Nie sposób przewidzieć wszystkich możliwości użycia pól, założenia zarówno wspólnych, jak i pojedynczych indeksów posiadających zakładaną przez nas moc i zajętą pamięć na dysku.
Ostatnimi czasy budowałem dość skomplikowany projekt, jeżeli chodzi o złożoność zapytań i wykonywanych przez nie operacje matematyczne. Pomimo tego, że aplikacja była doskonale przemyślana, a struktury bazy danych perfekcyjnie jej podporządkowane, gdzieś tkwił problem, bowiem jedno z zapytań generowało pozornie prosty (wizualnie) rezultat, baza reagowała na zapytanie dopiero po 2.5 sekundy dla 30k+ rekordów. Patrząc na strukturę kluczy i zapytania, zwłaszcza, że pola, na których operowałem były różnego rodzaju liczbami i datami zacząłem się poważnie martwić i rozkładać zapytanie na czynniki pierwsze, kończąc na warunkach. Wyobraźcie sobie moje zdziwienie, gdy doszedłem do tego, że całe obciążenie (ponad 2.3 sekundy) generował warunek:
WHERE DATE(ticket_date) >= " ... "
Gdzie ticket_date to pole typu DATETIME. Od razu doszedłem do wniosku, że w parze idzie złe przygotowanie danych przez PHP, a angażowana jest w to wszystko baza, na której forsuje się użycie funkcji DATE(). Przynajmniej dla 30k+ rekordów zindeksowanego pola. Prosty zabieg zamiany jednej linijki kodu na drugą przyniósł porządane efekty.
$aTerms[] = 'DATE(ticket_date) >= "' . $sDate . '"'
$aTerms[] = 'ticket_date >= "' . date('Y-m-d H:i:s', strtotime($sDate)) . '"'
Budując aplikację zwracam szczególną uwagę na strukturę bazy, indeksowanie pól, rysuję diagramy przewidujące wykorzystanie danych pod różne zapytania, ale… tak banalny błąd przy przeanalizowanej aplikacji rozłożył mnie na łopatki. Z drugiej strony, zapomniałem o jednej bardzo ważnej rzeczy: maksymalnym odciążeniu bazy danych przy preparowaniu argumentów warunków, skoro warunki te mogą być w odpowiedni i przede wszystkim szybki sposób spreparowane na poziomie modelu (abstrakcyjnie rzecz ujmując, pozbywam się pojęcia PHP), który przygotują zapytanie tylko do wykonania operacji na surowych danych, bez konieczności ich ewentualnego przeliczania. Oczywiście nie zawsze taki efekt da się uzyskać, ale należy to maksymalnie optymalizować.
Jedno jest wiadome: przeliczanie DATE() dla rekordów w warunku jest nieoptymalne dla pola DATETIME.
Usuwając coś permanentnie z bazy danych musimy być bardzo ostrożni, bowiem przywrócenie danych jest bardzo trudne, czasem niemożliwe. Podstawową strukturę bazy danych powinno się budować na samym początku tworzenia aplikacji, z biegiem czasu rozbudowywać ją, ale unikać przebudowywania. Niestety są przypadki, gdzie trzeba przebudować jedną rzecz, co powoduje zmianę w wielu warstwach nie tyle aplikacji, co strukturze bazodanowej.
Dziś postaram się opisać, jakie kroki trzeba wykonać, aby bezpiecznie usunąć zdublowane rekordy z bazy danych nie tracąc żadnych danych:
Po wykonaniu kroku pierwszego zabieramy się za kolejny. Jest to najważniejszy moment naszych operacji. Aby ułatwić zrozumienie problemu, podam przykład z życia. Aplikacja posiadała poważny błąd, który umożliwiał zdublowanie użytkowników, ściślej: można było zdublować username. Za każdym razem, gdy użytkownik się logował i pisał komentarze, był ich właścicielem, ale comment_author posiadały różne ID tego samego użytkownika. Zaraz po skopiowaniu bazy danych spróbowałem przepisać ID autorów komentarzy na pierwszy rekord identyfikujący użytkownika, jaki istnieje w tabeli użytkowników. Skonstruowałem zapytanie:
UPDATE cms_comments JOIN cms_members AS user_original ON(user_original.user_id = comment_author) SET comment_author = ( SELECT user_first.user_id FROM cms_members AS user_first WHERE user_first.user_name = user_original.user_name ORDER BY user_first.user_id ASC LIMIT 0, 1)
Usunięcie zdublowanych użytkowników było już tylko formalnością. Teraz się okaże, dlaczego zależało mi na wyciągnięciu dokładnie pierwszego rekordu reprezentującego “unikalnego” użytkownika: poniższe zapytanie (ALTER IGNORE TABLE ADD UNIQUE) usunie wszystkie kolejne rekordy oznaczone jako duplicated:
ALTER IGNORE TABLE cms_members ADD UNIQUE INDEX(user_name);
Krótki komentarz z manuala do ALTER TABLE:
IGNORE is a MySQL extension to standard SQL. It controls how ALTER TABLE works if there are duplicates on unique keys in the new table or if warnings occur when strict mode is enabled. If IGNORE is not specified, the copy is aborted and rolled back if duplicate-key errors occur. If IGNORE is specified, only the first row is used of rows with duplicates on a unique key, The other conflicting rows are deleted. Incorrect values are truncated to the closest matching acceptable value.
Pisząc ostatnie posty związane z bazami danych, mam nadzieję, że komuś się przydadzą.
Im więcej nietypowych rzeczy programuję, tym więcej nietypowych problemów musze pokonać. Co powiecie na sumę 2 liczb, z których jedna jest wartością NULL powstałą w wyniku działania SUM() lub pochodnych, gdzie nie odnaleziono żadnego rekordu.
Badamy:
SELECT 1+2+3
>> 6
SELECT 1+2+NULL
>> NULL
SELECT COALESCE( NULL, 0 )
>> 0
Zatem analogicznie do powyższego przykładu:
UPDATE users SET user_points = user_points + COALESCE((SELECT SUM( ... ) WHERE ...), 0)
Punkty użytkownika już zawsze będą się sumowały poprawnie :-)