← Blog
API13 min czytania

Protocol Buffers — format wymiany danych dla usług o wysokiej przepustowości

Protocol Buffers są domyślnym formatem wymiany danych w usługach o dużym ruchu. Najciekawsza w tym projekcie jest jednak polityka bezpieczeństwa: trójstopniowy model zagrożeń, który mówi wprost, co jest hartowane, a co nie.

Protocol Buffers to format, o którym większość programistów wie tyle, że jest szybszy od JSON-a i wymaga pliku ze schematem. To prawda, ale prawda niewystarczająca do podjęcia decyzji o wdrożeniu — bo najciekawsze w tym projekcie nie jest samo kodowanie danych, a zestaw zasad, na jakich wolno go używać.

Ten wpis jest więc nie tyle wprowadzeniem do formatu, ile przewodnikiem po tych zasadach. Zdecydowana większość z nich pochodzi z jednego dokumentu w repozytorium, który waży dwadzieścia kilobajtów i którego prawie nikt nie czyta — a który odpowiada na pytania, jakie zadaje się dopiero po incydencie.

Stan projektu

Repozytorium: protocolbuffers/protobuf. 72 014 gwiazdek, 16 276 forków, rdzeń w C++, historia na GitHubie od 2014, a prawa autorskie datowane na 2008. Commity wpadają codziennie, w tym w dniu powstania tego wpisu.

Wydania idą w rytmie kwartalnym: v36.1 z 31 sierpnia 2026, v36.0 z 20 sierpnia (poprzedzone dwoma wydaniami kandydackimi w lipcu i sierpniu), v35.1 z czerwca i v35.0 z maja 2026. Otwartych zgłoszeń jest 326, co przy tej skali jest liczbą zwyczajną.

Jedna uwaga praktyczna z README, warta powtórzenia, bo dotyczy sposobu użycia repozytorium: jeśli budujesz protobuf ze źródeł jako część swojego projektu, przypnij commit z gałęzi wydaniowej. Autorzy ostrzegają, że praca z czołem gałęzi głównej będzie się okresowo psuła przez zmiany niezgodne źródłowo, a nawet gałęzie wydaniowe mogą być niestabilne pomiędzy commitami wydań.

Kto jest właścicielem wygenerowanego kodu

To pytanie zadaje w firmie każdy prawnik, gdy pierwszy raz zobaczy katalog z plikami wygenerowanymi przez kompilator. Odpowiedź jest w pliku licencji i jest jednoznaczna.

Sama licencja to standardowy tekst BSD z trzema klauzulami, z prawami Google. Na jej końcu jest jednak akapit dodatkowy, którego nie ma w typowym BSD i który jest tutaj najważniejszy: kod wygenerowany przez kompilator Protocol Buffers należy do właściciela pliku wejściowego użytego do wygenerowania. Dalej doprecyzowanie: ten kod nie jest samodzielny i wymaga dolinkowania biblioteki wspierającej, a ta biblioteka jest objęta licencją powyżej.

Praktycznie znaczy to układ bardzo wygodny. Piszesz własny plik .proto, więc kod wygenerowany z niego jest twój — bez żadnych zobowiązań wynikających z licencji protobufa. Zobowiązania dotyczą wyłącznie biblioteki uruchomieniowej, którą linkujesz, a te są minimalne: zachowanie noty o prawach autorskich i brak używania nazwy Google do promocji produktów pochodnych.

To zresztą wyjaśnia, dlaczego GitHub raportuje dla tego repozytorium NOASSERTION zamiast BSD-3-Clause — automat rozpoznający licencje nie dopasowuje tekstu z dodatkowym akapitem. Nie ma tu żadnego haczyka, jest jedna klauzula więcej, i to na korzyść użytkownika.

Wersjonowanie, które myli

Rzecz, na której potyka się każdy przy pierwszej aktualizacji. Plik version.json w repozytorium pokazuje schemat wprost i warto go zobaczyć, bo tłumaczy zamieszanie:

protoc_version: 37-dev
lts: false
languages:
  cpp:         7.37-dev
  csharp:      3.37-dev
  java:        4.37-dev
  javascript:  3.37-dev
  objectivec:  5.37-dev
  php:         5.37-dev
  python:      7.37-dev
  ruby:        4.37-dev
  rust:        0.37-dev

Kompilator ma jeden numer — tutaj 37 — a każdy język ma własny numer główny, do którego dokleja się wspólny numer poboczny. Ta sama linia wydawnicza to więc `protoc` 37, biblioteka C++ w wersji 7.37, Javy w 4.37, Pythona w 7.37 i Ruby'ego w 4.37. Kto porównuje numery między językami, dochodzi do fałszywych wniosków o dojrzałości.

Dwie obserwacje z tej tabeli. Pierwsza: obsługa Rusta jest w wersji 0.37, czyli przed jedynką — to najmłodsza z wymienionych implementacji i przy wyborze języka warto o tym wiedzieć. Druga: jest tam flaga lts, więc część wydań jest oznaczana jako wsparcie długoterminowe. Przy planowaniu aktualizacji w firmie to właśnie ta flaga, a nie sam numer, powinna decydować o wyborze wersji.

Do tego dochodzi zasada, którą polityka bezpieczeństwa formułuje wprost: wersja wygenerowanego kodu i wersja biblioteki uruchomieniowej muszą się zgadzać dokładnie i obie należy trzymać na najnowszym wydaniu łatkowym. Uzasadnienie jest niepokojąco konkretne — pewne mało widoczne błędy o niskiej istotności są łatane wyłącznie na najnowszym wydaniu.

Traktuj plik .proto jak kod źródłowy

Pierwsza rekomendacja z listy dobrych praktyk i najbardziej zmieniająca sposób myślenia. Autorzy piszą: obchodź się z plikami .proto domyślnie jak z kodem, analogicznie do plików .java czy .cc, ponieważ wykonanie niezaufanego kodu może być wektorem ataku na łańcuch dostaw.

To brzmi nieoczywiście, bo plik ze schematem wygląda jak konfiguracja. Ale kompilator zamienia go w kod, który trafia do twojego procesu — więc cudzy plik .proto wzięty z internetu i przepuszczony przez kompilator jest dokładnie tym, co robi się z cudzym kodem. Praktyczny wniosek: schematy od kontrahentów przechodzą przegląd, a nie import.

Trójstopniowy model zagrożeń

To sedno dokumentu i rzecz, której nie widziałem w tak jasnej formie w żadnym innym projekcie tej klasy. Powierzchnie są podzielone na trzy poziomy, z różnymi obietnicami.

Poziom pierwszy — hartowane proaktywnie. Podatności na tych ścieżkach są traktowane priorytetowo, naprawiane i opisywane numerami CVE. Google używa tych powierzchni w publicznie dostępnych, wrażliwych punktach końcowych bez piaskownicy — co jest chyba najmocniejszym możliwym oświadczeniem o zaufaniu do własnego kodu. Jest tu zresztą zastrzeżenie warte odnotowania: jeśli zamknięcie luki wymaga zmiany łamiącej zgodność, taka zmiana zostanie wprowadzona, włącznie z naruszeniem gwarancji zgodności między wygenerowanym kodem a biblioteką.

Poziom drugi — hartowane reaktywnie. Zgłoszenia i poprawki są mile widziane, poważne problemy nadal priorytetowe, ale — cytując — nie łamiemy gwarancji zgodności wstecznej, by zaadresować problemy o niższej istotności poza podniesieniem wersji głównej. Konsekwencja jest podana bez ogródek: problemy o niskim wpływie mogą pozostać otwarte, jeśli z natury nie da się ich zamknąć bez zmiany łamiącej zgodność. Przy korzystaniu z tych powierzchni na potencjalnie złośliwych danych zaleca się izolację na poziomie aplikacji.

Poziom trzeci — poza modelem CVE, hartowane w miarę możliwości. Tu autorzy zakładają dane zaufane, a wyżej od bezpieczeństwa stawiają stabilność, wydajność i ergonomię. Nie znaczy to, że zgłoszenia są ignorowane — ale znaczy, że nie należy się spodziewać CVE.

Ten podział ma bezpośredni skutek operacyjny: ścieżka zgłoszenia podatności zależy od poziomu. Rzeczy z pierwszego poziomu idą do programu nagród Google, rzeczy poniżej jego progu ale wrażliwe — jako niepubliczne doradztwo bezpieczeństwa na GitHubie, a rzeczy z poziomu trzeciego wprost do publicznych zgłoszeń. Rzadko widzi się politykę, która mówi zgłaszającemu, którym kanałem iść, zamiast zostawiać go z jednym adresem na wszystko.

Granica, którą łatwo przeoczyć: parsowanie tak, serializacja nie

To najbardziej precyzyjny fragment całego dokumentu i jednocześnie ten, który najłatwiej źle zrozumieć.

Podstawowym przypadkiem hartowanym jest parsowanie binarnego formatu przewodowego. Model jest tu jasny: pliki .proto, kompilator i wygenerowany kod są w pełni zaufane, a przychodzące bajty są niezaufane i mogą być złośliwe. Biblioteka bezpiecznie przetworzy albo odrzuci dowolny strumień bajtów, bez narażania serwera na uszkodzenie pamięci, odczyty i zapisy poza zakresem czy zdalne wykonanie kodu.

Ale — i to jest ta granica — hartowaną powierzchnią jest wyłącznie parsowanie. Gdy wiadomość znajdzie się już w pamięci, jest w tym modelu traktowana jako obiekt zaufany. Serializacja i inne operacje na sparsowanych obiektach nie są objęte modelem zagrożeń. Autorzy podają przykład: parsowanie niezaufanego ładunku nie powinno doprowadzić do niekontrolowanej rekurencji, ale serializacja dowolnie ukształtowanego obiektu z pamięci może.

Analogia, którą sami przytaczają, jest znakomita: to podobne do tego, że nowoczesne przeglądarki nie rzucają błędu zakresu przy JSON.parse(), ale rzucają przy JSON.stringify().

Dlaczego to ma znaczenie w praktyce? Bo typowa usługa nie tylko parsuje — ona parsuje, przekształca i serializuje z powrotem. Jeśli twoja logika buduje z niezaufanego wejścia głęboko zagnieżdżoną strukturę i próbuje ją zserializować, jesteś poza obietnicą. To nie luka w protobufie, to obszar, w którym własne ograniczenia głębokości i rozmiaru są twoim zadaniem.

Warto dodać, że parsowanie formatu ProtoJSON — czyli schematów protobufa z kodowaniem w standardowym JSON-ie — jest objęte tym samym modelem co format binarny, choć autorzy nazywają je kodowaniem pomocniczym i zalecają preferowanie binarnego. Serializacja ProtoJSON, analogicznie, poza modelem.

Format tekstowy i wersje lite — dwa jasne ostrzeżenia

Na poziomie hartowanym reaktywnie są dwie rzeczy, o których trzeba wiedzieć, bo obie bywają używane niezgodnie z przeznaczeniem.

Format tekstowy jest przewidziany do lokalnego debugowania, testów i zarządzania zaufaną konfiguracją przez programistów. Autorzy piszą wprost, że nie jest przeznaczony do wymiany danych i nie zaleca się wystawiania publicznych usług, które go przyjmują. Do tego dochodzi przyznanie się do konkretnego braku: parsowanie formatu tekstowego nie wymusza obecnie żadnego limitu głębokości w kilku obsługiwanych środowiskach — limity są opcjonalne. Powodu też nie ukrywają: nie da się włączyć limitu domyślnie bez złamania zgodności wstecznej, ale mogą to zrobić w przyszłym wydaniu łamiącym zgodność.

Środowiska lite — C++ Lite i Java Lite — są przeznaczone na urządzenia mobilne i do przeglądarki, zoptymalizowane pod mały rozmiar binariów na koszt innych właściwości. Nadal są przewidziane do parsowania niezaufanych danych, ale ryzyko odmowy usługi jest w kontekście mobilnym uznawane za znacznie mniej istotne — zamrożenie jednej aplikacji na telefonie to nie to samo co zdjęcie publicznej usługi. Wniosek podany jest jednoznacznie: środowiska lite nie są zalecane dla serwerów wystawiających publiczne punkty końcowe.

Jest jeszcze trzecia kategoria, opisana zabawnie jako „rzucony niewłaściwy rodzaj wyjątku". W środowiskach bezpiecznych pamięciowo — Javie, Go, Pythonie, C# — otrzymanie zwykłego wyjątku w rodzaju przekroczenia zakresu tablicy albo przepełnienia stosu, zamiast zadeklarowanego wyjątku parsowania, jest traktowane jako istotny błąd do naprawienia, ale jako drobna sprawa bezpieczeństwa wobec uszkodzenia pamięci czy zdalnego wykonania kodu. Zalecenie dla nas: kod obsługujący zdalne wywołania powinien być odporny na nieoczekiwane wyjątki.

Który runtime wybrać

Polityka bezpieczeństwa zawiera rekomendacje, które są zaskakująco konkretne dla dokumentu tego typu — i przydatne przy wyborze technologii.

  • Java, wraz z powiązaniami dla Kotlina, jest zalecana jako domyślna dla najlepszej postawy bezpieczeństwa, ponieważ korzysta z gwarancji bezpieczeństwa pamięciowego maszyny wirtualnej.
  • C++ jest zalecany tam, gdzie liczy się wydajność — był obszernie fuzzowany i hartowany do tego stopnia, że Google używa go bez piaskownicy do parsowania niezaufanych danych binarnych na krytycznych powierzchniach. Z zastrzeżeniem, że programista musi pamiętać o ryzyku zarządzania pamięcią.
  • W językach, które mają kilka obsługiwanych implementacji, zdecydowanie preferuj domyślną. Pozostałe istnieją dla bardziej egzotycznych zastosowań i mogą nie mieć tyle uwagi poświęconej hartowaniu.
  • Szczegół dla naszej części ekosystemu: domyślne środowiska Pythona i PHP używają rozszerzenia w C, które jest bardziej zahartowane niż warianty zapasowe działające bez tego rozszerzenia. Instalacja, która z jakiegoś powodu nie zbuduje rozszerzenia, cofa się do implementacji mniej odpornej — i warto to sprawdzić, a nie założyć.

Limity obronne

Ostatnia rekomendacja z listy dobrych praktyk jest krótka i konkretna: nałóż warstwę stosującą limity obronne albo własne filtrowanie wokół parsowania. Autorzy podają przykład wart zapamiętania — gRPC domyślnie wymusza limit ładunku wynoszący 4 MiB — i zalecają rozsądne limity na wszystkich niezaufanych wejściach.

To jest dokładnie ta rzecz, którą przy wdrożeniu pomija się najczęściej, bo protobuf „sam się obroni". Owszem, obroni się przed uszkodzeniem pamięci. Nie obroni cię przed tym, że ktoś prześle sto megabajtów poprawnie zakodowanej wiadomości i twoja usługa zacznie alokować pamięć zgodnie z instrukcją.

Pułapki w jednym miejscu

  • Wersje wygenerowanego kodu i biblioteki muszą zgadzać się dokładnie, na najnowszym wydaniu łatkowym — część błędów łatana jest tylko tam.
  • Numery wersji różnią się między językami. Ta sama linia to `protoc` 37, C++ 7.37, Java 4.37, Python 7.37 — porównywanie ich wprost prowadzi do fałszywych wniosków.
  • Obsługa Rusta jest w wersji 0.37, czyli przed jedynką.
  • Hartowane jest parsowanie, nie serializacja. Budowanie i serializowanie struktur z niezaufanego wejścia jest poza obietnicą.
  • Format tekstowy nie ma domyślnego limitu głębokości w kilku środowiskach i nie jest przeznaczony do wymiany danych.
  • Środowiska lite nie są zalecane dla publicznych serwerów.
  • Plik .proto to kod. Cudze schematy przechodzą przegląd, nie import.
  • Domyślne środowiska Pythona i PHP wymagają rozszerzenia w C — wariant zapasowy jest mniej zahartowany.
  • Poprawka bezpieczeństwa może złamać zgodność, włącznie z gwarancją zgodności wygenerowanego kodu z biblioteką.
  • Problemy o niskiej istotności mogą zostać otwarte, jeśli nie da się ich zamknąć bez zmiany łamiącej zgodność.
  • Budując ze źródeł, przypnij commit z gałęzi wydaniowej — czoło gałęzi głównej okresowo się psuje.

Podsumowanie

Protocol Buffers są dobrym wyborem dla usług o dużym ruchu z powodów, które wszyscy znają: zwarte kodowanie, szybkie parsowanie, schemat jako kontrakt i generowanie kodu dla dziesięciu języków. Ale prawdziwą wartością tego projektu, którą doceniłem dopiero czytając jego dokumenty, jest to, jak precyzyjnie opisuje własne granice.

  • Wygenerowany kod należy do ciebie — licencja mówi to wprost, a zobowiązania dotyczą wyłącznie biblioteki uruchomieniowej.
  • Trójstopniowy model zagrożeń, który mówi, czego można się spodziewać na której powierzchni, i kieruje zgłoszenie do właściwego kanału.
  • Jasno postawiona granica między parsowaniem a serializacją, z analogią do zachowania przeglądarek przy JSON-ie.
  • Konkretne rekomendacje wyboru środowiska — Java dla bezpieczeństwa, C++ dla wydajności, zawsze implementacja domyślna.
  • Przyznane słabości, w tym brak domyślnego limitu głębokości w formacie tekstowym i powód, dla którego nie da się go dodać bez zmiany łamiącej zgodność.
  • Kwartalny rytm wydań z wydaniami kandydackimi i oznaczaniem wersji długoterminowego wsparcia.

Projekt jest wydany na licencji BSD z trzema klauzulami, z prawami Google, plus jednym akapitem dodatkowym — tym o własności wygenerowanego kodu. To licencja permisywna bez żadnych zobowiązań wobec twojego kodu: możesz używać komercyjnie, modyfikować i dostarczać klientom, zachowując notę o prawach autorskich i nie używając nazwy Google do promocji. Jedyne, co warto zapamiętać na przyszłość, to że BSD w wariancie trzyklauzulowym — inaczej niż Apache-2.0 — nie zawiera jawnego udzielenia licencji patentowej. Przy formacie stosowanym przez pół branży od blisko dwudziestu lat to rozważanie raczej teoretyczne, ale jeśli w waszej firmie ktoś prowadzi rejestr takich rzeczy, to jest właśnie ta rubryka do wypełnienia.