Deepak Kumar Singh

Cyber Lab: learn security by playing with it

Short chapters you can poke, each readable in plain words or technical detail, with a live feed of what is being reported today. Everything runs in the browser and progress stays on your device. This page starts with a hands-on lab on a question security testers meet constantly: why a plain scan can say all is well when the running page is not.

Static probe vs. deterministic browser capture

A cURL request or a static scanner reads the first response a server sends. Many modern web apps send a small HTML shell and build the real page in the browser: state is created, tokens are attached to requests and the page is rewritten after the scripts run. A deterministic real-browser capture waits for defined conditions, records every request, DOM change and console message, and then asserts on what it recorded, the same way every time. Pick a scenario, then run the comparison.

How a deterministic capture works

  1. Pin the environment. Fix the viewport, locale, time zone and cache, and block service workers, so two runs differ only if the app does.
  2. Intercept the requests. Route every request through the test so each one is recorded, and so slow, failing or expired responses can be forced on demand.
  3. Wait for networkidle and hydration. Wait for the network to go quiet and for an explicit hydration-complete marker, so the audit never measures a half-built page.
  4. Observe the DOM. Install a MutationObserver before any page script runs, so every insertion and rewrite is seen in order.
  5. Assert on the HAR and the trace. Check the recorded requests and DOM events against rules. A failure carries its own proof: the exact request, the timing and the trace.

Scenario 1: Hydration race and a leaked bearer token

The server sends an empty shell, so there is nothing to find in it. After the scripts run, an analytics client starts before the auth store has finished loading and copies the access token into a request URL, while the same token sits in a global object in memory.

Timeline for scenario 1Four lanes over three seconds. A cURL probe sees only the first response in the HTML shell lane. A browser capture records all four lanes. The defect, token in URL, appears later in the network lane, where the probe never looks.HTML shellScript hydrationNetworkDOMcURL sees only thistoken in URL0 s3 sdashed box: what a browser capture records
Where the defect lives on the timeline, and what each kind of probe can see of it.

Standard static or cURL scanner

$ curl -s https://app.example.test/dashboard | head -c 200
<!doctype html><html><head><title>Dashboard</title></head>
<body><div id="root"></div>
<script src="/static/app.4f2c.js" defer></script></body></html>

scanner: 1 URL, 0 findings

Result: 0 findings. The token does not exist yet in the first response. It appears only after JavaScript runs and the auth store hydrates, so a probe that never runs the page can never see it, whatever signatures it carries.

Deterministic Playwright audit

  1. Pin the environmentContext pinned: 1280x800, UTC, cache off, service workers blocked, HAR recording on.
  2. Intercept the requestsEvery request is recorded, including third-party hosts; nothing is stubbed away.
  3. Wait for networkidle and hydrationnetworkidle reached at 1.9 s, then the page's own data-hydrated marker flips to true at 1.6 s.
  4. Observe the DOMThe observer shows window.__APP_STATE__ gaining an accessToken key while the shell is still being built.
  5. Assert on the HAR and the traceHAR assertion: request 14, a GET to telemetry.example.test, carries ?t=<token>, sent 410 ms before the hydration barrier.

Finding

A bearer token is sent in a URL to a third-party host before hydration completes, because the analytics client reads the auth store before it is ready. The same token is reachable from a global in memory.

Proof: an illustrative Playwright test

import { test, expect } from '@playwright/test';

test('no bearer token leaves the app before hydration finishes', async ({ page }) => {
  const leaks: string[] = [];
  page.on('request', (req) => {
    const url = req.url();
    const auth = req.headers()['authorization'] ?? '';
    const thirdParty = !url.startsWith('https://app.example.test');
    if (/[?&](t|token|access_token)=/.test(url) || (thirdParty && auth.startsWith('Bearer '))) {
      leaks.push(req.method() + ' ' + url);
    }
  });

  await page.goto('https://app.example.test/dashboard');
  await page.waitForLoadState('networkidle');
  await page.waitForFunction(() => document.documentElement.dataset.hydrated === 'true');

  expect(leaks, 'tokens sent in a URL or to a third party').toEqual([]);
  const keys = await page.evaluate(() => Object.keys((window as any).__APP_STATE__ ?? {}));
  expect(keys).not.toContain('accessToken');
});

How to fix it

  • Send the token only in an Authorization header, and only to the first-party API.
  • Start analytics after the auth store reports ready, not on a timer.
  • Keep credentials out of globals; hold them in a closure or an HttpOnly cookie.

Scenario 2: An authenticated API waterfall and an unbounded retry storm

A dashboard makes six dependent calls one after another. When the access token expires, every call gets a 401 and retries at once with no back-off, and each retry triggers its own token refresh, so one page view turns into a burst of requests.

Timeline for scenario 2Four lanes over three seconds. A cURL probe sees only the first response in the HTML shell lane. A browser capture records all four lanes. The defect, 47 requests, appears later in the network lane, where the probe never looks.HTML shellScript hydrationNetworkDOMcURL sees only this47 requests0 s3 sdashed box: what a browser capture records
Where the defect lives on the timeline, and what each kind of probe can see of it.

Standard static or cURL scanner

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  -H "Authorization: Bearer $TOKEN" https://app.example.test/api/summary
200 0.091s

scanner: 1 endpoint, 200 OK, fast, 0 findings

Result: 0 findings. One request with a valid token looks healthy and fast. The defect is in the sequence and the retry behaviour across many requests, which only exists inside a running page with an expiring session.

Deterministic Playwright audit

  1. Pin the environmentContext pinned, with the session starting one second from expiry.
  2. Intercept the requestsAPI calls are routed through the test: the first 401 is forced, every call is timestamped.
  3. Wait for networkidle and hydrationnetworkidle only arrives at 3.4 s; a naive audit would have stopped at the first quiet moment.
  4. Observe the DOMThe observer shows the loading spinner re-rendering nine times as retries land.
  5. Assert on the HAR and the traceHAR assertion: 47 API requests for one page view, and the refresh endpoint called 9 times at once; the budget was 12 and 1.

Finding

Expiry triggers a retry storm: immediate retries with no exponential back-off or jitter, and no single-flight guard on the token refresh. The sequential waterfall also doubles the time to a usable page.

Proof: an illustrative Playwright test

import { test, expect } from '@playwright/test';

test('an expired token causes one refresh and a bounded number of calls', async ({ page }) => {
  let expired = true;
  const calls: string[] = [];

  await page.route('https://app.example.test/api/**', (route) => {
    const url = route.request().url();
    calls.push(url);
    if (url.endsWith('/api/auth/refresh')) { expired = false; return route.fulfill({ status: 200, json: { ok: true } }); }
    return route.fulfill({ status: expired ? 401 : 200, json: {} });
  });

  await page.goto('https://app.example.test/dashboard');
  await page.waitForLoadState('networkidle');

  const refreshes = calls.filter((u) => u.endsWith('/api/auth/refresh')).length;
  expect(refreshes, 'refresh calls').toBeLessThanOrEqual(1);
  expect(calls.length, 'API calls for one page view').toBeLessThanOrEqual(12);
});

How to fix it

  • Make the token refresh single-flight: one in-flight refresh that every waiting call shares.
  • Retry with exponential back-off and jitter, with a hard cap.
  • Fetch independent data in parallel instead of in a chain.

Scenario 3: DOM-based XSS through a postMessage origin bypass

The page listens for messages and checks the sender with a substring test on the origin. A look-alike origin passes the check, the payload is stored in state, and a later asynchronous render writes it into the page as HTML.

Timeline for scenario 3Four lanes over three seconds. A cURL probe sees only the first response in the HTML shell lane. A browser capture records all four lanes. The defect, markup runs, appears later in the dom lane, where the probe never looks.HTML shellScript hydrationNetworkDOMcURL sees only thismarkup runs0 s3 sdashed box: what a browser capture records
Where the defect lives on the timeline, and what each kind of probe can see of it.

Standard static or cURL scanner

$ curl -s https://app.example.test/dashboard | grep -c "message"
0

scanner: no listener, no sink, 0 findings

Result: 0 findings. The listener, the weak origin check and the HTML sink all live in the script bundle, and they only connect at runtime: a message arrives, state changes, an async render fires. A probe that fetches the shell sees none of it.

Deterministic Playwright audit

  1. Pin the environmentContext pinned; the look-alike origin app.example.test.attacker.example is served from the test itself.
  2. Intercept the requestsThe popup request is intercepted and answered with a page that posts a crafted message back to its opener.
  3. Wait for networkidle and hydrationHydration barrier first, then two animation frames after the message, so the async state flush has run.
  4. Observe the DOMThe observer records an <img> with an onerror attribute inserted into the banner element.
  5. Assert on the HAR and the traceTrace assertion: window.__pwned is set, so attacker-controlled markup executed in the page's origin.

Finding

The origin check uses includes() on the sender's origin, so any host whose name contains the real one is accepted. The message body is later assigned with innerHTML after an async state update, which turns a messaging bug into DOM-based XSS.

Proof: an illustrative Playwright test

import { test, expect } from '@playwright/test';

test('a look-alike origin cannot push markup into the page', async ({ page, context }) => {
  await context.route('https://app.example.test.attacker.example/**', (route) =>
    route.fulfill({
      contentType: 'text/html',
      body: '<script>opener.postMessage({ type: "banner", html: "<img src=x onerror=window.__pwned=1>" }, "*")</script>',
    }));

  await page.goto('https://app.example.test/dashboard');
  await page.waitForFunction(() => document.documentElement.dataset.hydrated === 'true');

  const popup = page.waitForEvent('popup');
  await page.evaluate(() => window.open('https://app.example.test.attacker.example/'));
  await popup;

  // two animation frames: let the asynchronous state update and render run
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
  expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});

How to fix it

  • Compare the origin for exact equality against an allow-list, never with includes() or a regular expression.
  • Render message data with textContent, or sanitise it, and never assign it with innerHTML.
  • Enforce Trusted Types so an unsafe HTML sink throws instead of executing.

Every host, request count and timing in this lab is invented for teaching. Nothing here comes from a real system or engagement, and the code is an illustrative sketch rather than a tested tool.

The same practice, shown as a transit across a star: open it on the 3D map. The chapters below are on the map too: open the interactive Cyber Lab.

More in the Cyber Lab

Chapters

Missions

Ten short, practical checklists for ordinary people.

There is also a beginner capture-the-flag with eleven challenges, and a Live tab that shows a daily digest of known-exploited vulnerabilities, advisories and security news, each linked to the original publisher.

Open the interactive Cyber Lab.