CORS and CSRF Security Lab

Build a local lab to expose CORS limits and block CSRF transfer attacks.

Introduction

30 Second Summary

A browser can refuse to show a page's response while still sending a request that changes data behind the scenes. That gap can make a security control look stronger than it is.

In this project, you will build a three-origin account security lab with Node.js. You will watch CORS hide an account response while CSRF still changes the balance before adding a synchronizer token that rejects the forged transfer.

What You'll Build

You'll demo an attacker page that cannot read the account response but can still lower the balance, then turn on token protection to make the same forged transfer return 403.

By the end of this project, you'll have:

  • A trusted account dashboard that starts a cookie-backed session and displays a balance of $100.
  • An attacker page that proves blocked response access can exist beside a successful forged transfer.
  • A protected transfer flow that lets you inspect an OPTIONS preflight, a successful trusted request, and a rejected forged request in Microsoft Edge DevTools.
  • Secret Mission: Add complete Origin verification as a second server-side defense for state-changing requests.

Are there any prerequisites?

Basic HTML and JavaScript are enough. The walkthrough starts with a local health check before introducing the security behavior.

Before We Start

This step locks in the security target you will prove in the browser. The attacker page first changes the cookie-authenticated balance with a forged transfer before the synchronizer token rejects that state-changing request.

Set Up the Local Security Lab

Your security lab will soon juggle three separate origins. A tiny smoke test first proves that your Windows toolchain can serve a response before the security behavior adds complexity.

Node.js powers each local server in this project. This first route turns your setup into a visible browser response.

In this step, get ready to:
  • Open a new cors-csrf-lab workspace in Visual Studio Code.
  • Verify Node.js v24.21.0 LTS from Windows PowerShell.
  • Run the health route in Microsoft Edge.
Create the lab folder and verify Node.js

A workspace keeps the lab files in one named folder. Its integrated terminal also starts from that folder, which keeps every later command pointed at the right project.

You may see a trust prompt

Visual Studio Code may ask whether you trust files in the folder after it opens. This folder is empty and created by you.

  • Press the Windows key to open search.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu.
  • Select Open Folder....
  • Choose your Desktop in the folder picker.
  • Select New Folder.
  • Enter cors-csrf-lab as the folder name.
  • Select Select Folder. Confirm workspace trust if prompted.

Good. Your lab now has a fixed home on the Desktop.

  • Select View from the menu bar.
  • Select Terminal to open the integrated terminal.
  • Select Windows PowerShell from the terminal profile selector if another shell opens.
  • Confirm that the terminal path ends with Desktop\cors-csrf-lab.
  • Check the installed Node.js version by running this command:
node --version

✔️ I see the current LTS version

If PowerShell prints v24.21.0, your runtime matches the reference environment. Node.js is ready for the lab.

ⓧ I see an older version

Your installed runtime is older than the reference version. Update it before building the server so your environment matches the project.

  • Open the official Node.js download page.
  • Download Node.js v24.21.0 LTS for Windows.
  • Complete the installer using its default options.
  • Restart Visual Studio Code after the installation.
  • Use the File menu's Open Folder... command to reopen cors-csrf-lab from your Desktop.
  • Press Ctrl+` to open a fresh integrated terminal.
  • Select Windows PowerShell from the terminal profile selector if needed.
  • Verify the updated version by running this command:
node --version

PowerShell should now print v24.21.0.

ⓧ The command is unavailable

PowerShell cannot currently find the Node.js executable. Installing the official LTS release adds the runtime needed by the lab.

  • Open the official Node.js download page.
  • Download Node.js v24.21.0 LTS for Windows.
  • Complete the installer using its default options.
  • Restart Visual Studio Code after the installation.
  • Use the File menu's Open Folder... command to reopen cors-csrf-lab from your Desktop.
  • Press Ctrl+` to open a fresh integrated terminal.
  • Select Windows PowerShell from the terminal profile selector if needed.
  • Check the installation by running this command:
node --version

PowerShell should now print v24.21.0.

  • Record the final output here: your Node.js version.

Node.js still not available?

  • Close every integrated terminal that was open during installation.
  • Restart Visual Studio Code so it loads the updated Windows path.
  • Return to the cors-csrf-lab folder before opening a new Windows PowerShell terminal.

Help me diagnose why node --version is unavailable.

Build the health-check server

An HTTP health route gives the browser one predictable response to request. A successful response proves that Node.js can read your file and listen on a local port.

  • Create server.js inside cors-csrf-lab by running this command:
New-Item -Path 'server.js' -ItemType File

PowerShell lists the new file. You will also see server.js in the Explorer sidebar.

  • Select server.js in the Explorer sidebar.
  • Paste this smoke-test server into the empty file:
const { createServer } = require('node:http');

const server = createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    res.writeHead(200, {
      'Content-Type': 'text/plain; charset=utf-8',
    });
    res.end('Security lab ready');
    return;
  }

  res.writeHead(404, {
    'Content-Type': 'text/plain; charset=utf-8',
  });
  res.end('Not found');
});

server.listen(3000, () => {
  console.log('Security lab: http://localhost:3000');
});

What does this server do?

  • The createServer() function creates the local server that receives each request.
  • The /health condition accepts only GET requests.
  • The first writeHead() call sets a successful status with a plain-text content type.
  • The first end() call sends Security lab ready to the browser.
  • The fallback returns 404 for every unmatched request.
  • The server.listen() call binds the smoke test to port 3000.
  • Save server.js.

✔️ Awesome, I've got everything!

Your smoke-test server now matches the project checkpoint.

ⓧ I'd like to double check the full code

const { createServer } = require('node:http');

const server = createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    res.writeHead(200, {
      'Content-Type': 'text/plain; charset=utf-8',
    });
    res.end('Security lab ready');
    return;
  }

  res.writeHead(404, {
    'Content-Type': 'text/plain; charset=utf-8',
  });
  res.end('Not found');
});

server.listen(3000, () => {
  console.log('Security lab: http://localhost:3000');
});
Run the health check in Microsoft Edge

Before you start the server, what do you expect Microsoft Edge to show when it requests /health?

  • Start the smoke-test server from the integrated Windows PowerShell terminal by running this command:
node server.js

PowerShell prints Security lab: http://localhost:3000. The terminal stays occupied while the server listens for requests.

  • Press the Windows key to open search.
  • Type Microsoft Edge and press Enter to open it.
  • Enter http://localhost:3000/health in the address bar and press Enter.

You will see Security lab ready on the page. That response confirms your local Node.js toolchain can serve the lab.

Health check not loading?

  • Confirm that the integrated terminal is still running the server process.
  • Check that the browser URL uses port 3000 with the path /health.
  • Confirm that server.js is saved inside cors-csrf-lab.

Help me find why the health route is not loading.

  • Switch back to Visual Studio Code.
  • Press Ctrl+C in the Windows PowerShell terminal to stop the server.

The PowerShell prompt returns. Your smoke-test server is saved and ready for the account API that comes next.

Your Windows toolchain has passed its first end-to-end check. Next up, you will turn this single route into a trusted account dashboard with a cookie-authenticated API.

Build the Trusted Account Flow

Your Node.js smoke test proved that the local server can answer browser requests. The next goal is a trusted dashboard that reads account data from a separate HTTP origin.

The browser needs an explicit CORS allowlist before the trusted frontend can read the cookie-authenticated API. This gives you a working account flow before the attacker tests its boundary.

In this step, get ready to:
  • Build an account API with a credentialed CORS allowlist.
  • Create a trusted dashboard that starts a session and displays the balance.
  • Inspect the trusted account response in Microsoft Edge DevTools.
Build the account API and trusted server

Ports 3000 and 8000 create separate origins. The API checks the request origin before adding headers that let the trusted dashboard read its response.

The API also creates a host-only session cookie because the cookie has no Domain attribute. Its security attributes prevent JavaScript access and limit when the browser sends it.

  • In server.js, select all of the smoke-test code.
  • Replace it with the imports, server settings, and response helpers below:
const { createServer } = require('node:http');
const { randomBytes } = require('node:crypto');
const { readFile } = require('node:fs/promises');

const TRUSTED_PORT = 3000;
const API_PORT = 8000;
const TRUSTED_ORIGIN = `http://localhost:${TRUSTED_PORT}`;
const SESSION_COOKIE_NAME = 'session';
const SESSION_ID = randomBytes(16).toString('hex');

let balance = 100;

function sendJson(res, statusCode, payload) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
  });
  res.end(JSON.stringify(payload));
}

function sendText(res, statusCode, text) {
  res.writeHead(statusCode, {
    'Content-Type': 'text/plain; charset=utf-8',
  });
  res.end(text);
}

What does this code set up?

  • The port constants keep the trusted frontend address separate from the account API address.
  • The random session ID gives this server process an unpredictable cookie value.
  • The response helpers keep JSON and plain-text responses consistent.
  • Add the CORS and session helpers directly below sendText():
function applyCors(req, res) {
  if (req.headers.origin === TRUSTED_ORIGIN) {
    res.setHeader('Access-Control-Allow-Origin', TRUSTED_ORIGIN);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
}

function hasSession(req) {
  const cookieHeader = req.headers.cookie ?? '';
  return cookieHeader
    .split(';')
    .some((part) => part.trim() === `${SESSION_COOKIE_NAME}=${SESSION_ID}`);
}

function setSessionCookie(res) {
  res.setHeader(
    'Set-Cookie',
    `${SESSION_COOKIE_NAME}=${SESSION_ID}; HttpOnly; SameSite=Strict; Path=/`,
  );
}

How does the boundary work?

  • The CORS helper adds response headers only when the request comes from the complete trusted origin.
  • The session check searches the incoming cookie header for the server-generated session value.
  • The cookie helper adds HttpOnly, SameSite=Strict, and Path=/ attributes.
  • Add the HTML file helper below setSessionCookie():
async function serveHtml(res, fileName) {
  try {
    const html = await readFile(fileName, { encoding: 'utf8' });
    res.writeHead(200, {
      'Content-Type': 'text/html; charset=utf-8',
    });
    res.end(html);
  } catch {
    sendText(res, 500, `Could not read ${fileName}`);
  }
}

What does this helper do?

  • The helper reads an HTML file as text before returning it to the browser.
  • The fallback response makes a missing file visible through an HTTP status of 500.
  • Add the account API below serveHtml():
const apiServer = createServer((req, res) => {
  applyCors(req, res);

  if (req.method === 'GET' && req.url === '/account') {
    if (!hasSession(req)) {
      setSessionCookie(res);
    }

    sendJson(res, 200, { balance });
    return;
  }

  sendJson(res, 404, { error: 'Not found' });
});

How does the account route work?

  • Every API request passes through the CORS allowlist before route handling begins.
  • The /account route sets a session cookie when the browser does not already have the current session.
  • The successful response returns the in-memory balance as JSON.
  • Add the trusted frontend server and startup listeners below apiServer:
const trustedServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    sendText(res, 200, 'Security lab ready');
    return;
  }

  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'trusted.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

apiServer.listen(API_PORT, () => {
  console.log(`Account API: http://localhost:${API_PORT}`);
});

trustedServer.listen(TRUSTED_PORT, () => {
  console.log(`Trusted dashboard: http://localhost:${TRUSTED_PORT}`);
});

What starts when you run the file?

  • The trusted server keeps the existing health route and serves trusted.html from its home route.
  • The API listens on port 8000.
  • The trusted dashboard listens on port 3000.
  • Save server.js.

✔️ Awesome, I've got everything!

Your saved server.js now defines the account API and trusted frontend server.

ⓧ I'd like to double check the full code

const { createServer } = require('node:http');
const { randomBytes } = require('node:crypto');
const { readFile } = require('node:fs/promises');

const TRUSTED_PORT = 3000;
const API_PORT = 8000;
const TRUSTED_ORIGIN = `http://localhost:${TRUSTED_PORT}`;
const SESSION_COOKIE_NAME = 'session';
const SESSION_ID = randomBytes(16).toString('hex');

let balance = 100;

function sendJson(res, statusCode, payload) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
  });
  res.end(JSON.stringify(payload));
}

function sendText(res, statusCode, text) {
  res.writeHead(statusCode, {
    'Content-Type': 'text/plain; charset=utf-8',
  });
  res.end(text);
}

function applyCors(req, res) {
  if (req.headers.origin === TRUSTED_ORIGIN) {
    res.setHeader('Access-Control-Allow-Origin', TRUSTED_ORIGIN);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
}

function hasSession(req) {
  const cookieHeader = req.headers.cookie ?? '';
  return cookieHeader
    .split(';')
    .some((part) => part.trim() === `${SESSION_COOKIE_NAME}=${SESSION_ID}`);
}

function setSessionCookie(res) {
  res.setHeader(
    'Set-Cookie',
    `${SESSION_COOKIE_NAME}=${SESSION_ID}; HttpOnly; SameSite=Strict; Path=/`,
  );
}

async function serveHtml(res, fileName) {
  try {
    const html = await readFile(fileName, { encoding: 'utf8' });
    res.writeHead(200, {
      'Content-Type': 'text/html; charset=utf-8',
    });
    res.end(html);
  } catch {
    sendText(res, 500, `Could not read ${fileName}`);
  }
}

const apiServer = createServer((req, res) => {
  applyCors(req, res);

  if (req.method === 'GET' && req.url === '/account') {
    if (!hasSession(req)) {
      setSessionCookie(res);
    }

    sendJson(res, 200, { balance });
    return;
  }

  sendJson(res, 404, { error: 'Not found' });
});

const trustedServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    sendText(res, 200, 'Security lab ready');
    return;
  }

  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'trusted.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

apiServer.listen(API_PORT, () => {
  console.log(`Account API: http://localhost:${API_PORT}`);
});

trustedServer.listen(TRUSTED_PORT, () => {
  console.log(`Trusted dashboard: http://localhost:${TRUSTED_PORT}`);
});

What should match?

Compare the imports, constants, helpers, route order, and listener ports with your saved file. Exact names matter because later steps extend these same servers.

  • Start both local servers from the existing PowerShell terminal by running:
node server.js

What should PowerShell show?

PowerShell prints Account API: http://localhost:8000 and Trusted dashboard: http://localhost:3000.

The command keeps PowerShell busy while both servers accept requests. Leave it running for the browser checks.

Servers not starting?

Check that every opening brace and parenthesis in server.js has a matching closing character. A syntax error usually points near the first incomplete block.

If a port is already busy, stop the earlier server process with Ctrl+C before running the command again.

Help me diagnose why my two Node.js servers do not start.

Create the trusted dashboard

The dashboard calls the API with fetch() from the trusted origin. The credentials: 'include' option lets the browser store and send the session cookie during cross-origin requests.

  • In Visual Studio Code, create trusted.html inside the open cors-csrf-lab folder.
  • Add the dashboard structure to trusted.html by pasting:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Trusted Account Dashboard</title>
</head>
<body>
  <main>
    <h1>Trusted Account Dashboard</h1>
    <p>This page is the only frontend origin allowed to read the account API.</p>
    <div id="balance">Balance not loaded</div>
    <button id="loadAccount">Start session / Refresh account</button>
    <pre id="status">Ready.</pre>
  </main>

What does the page contain?

  • The balance area shows whether account data has loaded.
  • The session button gives you a visible way to call the API.
  • The status area reports progress or request errors.
  • Add the script opening and display helper directly below </main>:
  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const balanceElement = document.querySelector('#balance');
    const statusElement = document.querySelector('#status');

    function showAccount(data) {
      balanceElement.textContent = `Balance: $${data.balance}`;
      statusElement.textContent = 'Session started.';
    }

How does the page track its output?

  • The API origin reuses the browser hostname while targeting port 8000.
  • The DOM references point to the balance and status elements.
  • The display helper turns returned account data into visible text.
  • Add the account request directly below showAccount():
    async function loadAccount() {
      statusElement.textContent = 'Loading account...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error ?? `Request failed with ${response.status}`);
        }

        showAccount(data);
      } catch (error) {
        statusElement.textContent = error.message;
      }
    }

What happens during the request?

  • The status changes immediately so you can see that the request started.
  • The credentialed request lets the browser accept the session cookie from the API.
  • A successful response passes its JSON data to showAccount().
  • A failed response places its message in the dashboard status area.
  • Connect the button to loadAccount() by adding the closing lines:
    document.querySelector('#loadAccount').addEventListener('click', loadAccount);
  </script>
</body>
</html>

What completes the interaction?

The event listener runs loadAccount() whenever you select the session button. The remaining tags close the script and document.

  • Save trusted.html.

✔️ Awesome, I've got everything!

Your saved trusted.html now contains the account display and credentialed request.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Trusted Account Dashboard</title>
</head>
<body>
  <main>
    <h1>Trusted Account Dashboard</h1>
    <p>This page is the only frontend origin allowed to read the account API.</p>
    <div id="balance">Balance not loaded</div>
    <button id="loadAccount">Start session / Refresh account</button>
    <pre id="status">Ready.</pre>
  </main>

  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const balanceElement = document.querySelector('#balance');
    const statusElement = document.querySelector('#status');

    function showAccount(data) {
      balanceElement.textContent = `Balance: $${data.balance}`;
      statusElement.textContent = 'Session started.';
    }

    async function loadAccount() {
      statusElement.textContent = 'Loading account...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error ?? `Request failed with ${response.status}`);
        }

        showAccount(data);
      } catch (error) {
        statusElement.textContent = error.message;
      }
    }

    document.querySelector('#loadAccount').addEventListener('click', loadAccount);
  </script>
</body>
</html>

What should match?

Compare the element IDs, API origin, fetch options, and event listener with your saved file. These identifiers connect the page controls to the account request.

  • Return to Microsoft Edge.
  • Enter http://localhost:3000 in the address bar.
  • Select Start session / Refresh account.

You will see Balance: $100 above Session started. Great work. Your trusted frontend can now read the account API with a browser-managed session.

Dashboard not showing the balance?

Confirm that PowerShell still shows both server addresses. The dashboard cannot load account data after the server process stops.

Check that trusted.html sits beside server.js inside cors-csrf-lab.

Help me diagnose why the trusted dashboard does not display the $100 balance.

Inspect the credentialed CORS response

The Network tool in Microsoft Edge DevTools records the browser request and API response. Its headers show why the browser permits this trusted page to read the account data.

  • Press Ctrl+Shift+J to open Microsoft Edge DevTools.
  • Select the Network tab.
  • Clear the existing Network Log.

Before you refresh the account, do you expect the API to share its response with this complete origin?

  • Select Start session / Refresh account on the dashboard.
  • Select the /account request in the Network Log.
  • Select the Headers panel.
  • Confirm the response includes Access-Control-Allow-Origin: http://localhost:3000.
  • Confirm the response includes Access-Control-Allow-Credentials: true.
  • Confirm the session cookie includes HttpOnly; SameSite=Strict; Path=/.
  • Select the Response panel.
  • Confirm the response contains a balance of 100.

The API shares this response because the request carries the exact trusted origin. The dashboard can read the JSON because the response also permits credentials.

What does this prove?

The origin allowlist controls which frontend JavaScript can read the API response. The explicit origin is required for this credentialed request.

The browser stores the host-only session cookie without exposing it to page JavaScript. That cookie now authenticates later requests to the API host.

Cannot find the account request?

Keep the Network tab selected before using the account button again. The new /account row should appear after the click.

If the response lacks CORS headers, confirm the browser address is exactly http://localhost:3000.

Help me understand why the trusted account response is missing its credentialed CORS headers.

Your trusted account flow is working across two origins. Next, you will add an attacker page that tests whether blocked response access also prevents a state change.

Watch CORS Fail to Stop CSRF

Your trusted dashboard proved that CORS can grant one origin access to a cookie-authenticated API. The next test asks whether that access rule also proves that a request came from your trusted interface.

An attacker origin can lose access to the response while the browser still submits a cookie-backed form. This gap creates cross-site request forgery when the API treats the session cookie as sufficient proof of intent.

In this step, get ready to:
  • Add a third local origin that serves an attacker page.
  • Compare blocked fetch access with an executed form submission.
  • Prove the forged request changes the authenticated balance.
Add the attacker origin

A different port creates a different HTTP origin. Port 5000 gives the attacker page its own origin while keeping every part of the lab on your computer.

  • Return to the terminal from earlier.
  • Press Ctrl+C if the server is still running.
  • In server.js, add this attacker port below API_PORT:
const ATTACKER_PORT = 5000;

What does this line do?

The ATTACKER_PORT constant gives the attacker server a separate origin at port 5000.

  • In server.js, add this server below the existing trustedServer block:
const attackerServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'attacker.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

What does this server do?

  • The attackerServer serves attacker.html when the browser requests its home page.
  • Every other path receives the same plain-text 404 response pattern as the trusted server.
  • At the bottom of server.js, add this listener after the existing trusted server listener:
attackerServer.listen(ATTACKER_PORT, () => {
  console.log(`Attacker page: http://localhost:${ATTACKER_PORT}`);
});

What starts the attacker origin?

The listener starts the third server on ATTACKER_PORT. Its console message gives you the URL to test.

  • Save server.js.
  • Start all three servers by running this command:
node server.js

What does this command prove?

Node.js loads the updated server file. You should see console messages for the account API, trusted dashboard, and attacker page.

You should see Attacker page: http://localhost:5000 beneath the other two server addresses.

Missing the attacker address?

Check that attackerServer.listen sits outside the trusted server listener. Confirm that ATTACKER_PORT uses the value 5000.

Ask for help with the server wiring:

The attacker server now needs a page to return. The first version gives you both attack controls before any JavaScript behavior is attached.

  • Use the file controls in Visual Studio Code to create attacker.html beside server.js.
  • Build the attacker page by adding this HTML:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Attacker Test Page</title>
</head>
<body>
  <main>
    <h1>Attacker Test Page</h1>
    <p>First try to read the API response. Then try to submit a form-shaped state change.</p>

    <button id="readAccount">Try reading account with fetch</button>

    <form id="csrfForm" method="post" target="attackResult">
      <input type="hidden" name="amount" value="25">
      <button type="submit">Submit forged $25 transfer</button>
    </form>

    <iframe name="attackResult" title="Hidden attack result" hidden></iframe>
    <pre id="status">Ready.</pre>
  </main>
</body>
</html>

What does this page contain?

  • The first button becomes the attacker JavaScript's attempt to read the account API.
  • The form sends amount=25 through a normal form-shaped request.
  • The hidden iframe receives the form response without navigating the visible attacker page.
  • Save attacker.html.
  • Switch back to Microsoft Edge.
  • Open http://localhost:5000 in a new tab.

You should see the attacker heading, two red-team test buttons, and a status area that reads Ready..

Attacker page not loading?

Confirm that attacker.html is beside server.js. Check the terminal for a message mentioning that the file could not be read.

Get help checking the file route:

Demonstrate blocked response access

The attacker script will request the same account endpoint as the trusted dashboard. The API only returns credentialed CORS permission to http://localhost:3000.

  • In attacker.html, add this script immediately before </body>:
  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const statusElement = document.querySelector('#status');
    const csrfForm = document.querySelector('#csrfForm');

    csrfForm.action = `${API_ORIGIN}/transfer`;

    async function tryReadingAccount() {
      statusElement.textContent = 'Trying cross-origin fetch...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();
        statusElement.textContent = `Unexpectedly read balance: $${data.balance}`;
      } catch (error) {
        statusElement.textContent = `Blocked by CORS: ${error.message}`;
      }
    }

    csrfForm.addEventListener('submit', () => {
      statusElement.textContent =
        'Forged form submitted. Refresh the trusted dashboard to check whether state changed.';
    });

    document.querySelector('#readAccount').addEventListener('click', tryReadingAccount);
  </script>

What does the attacker script do?

  • The credentialed fetch() asks the browser to include the local session cookie.
  • The try branch would expose the balance if the attacker origin received CORS permission.
  • The catch branch displays the blocked-access result when the browser withholds the response.
  • The form action points at the API's /transfer endpoint for the next test.
  • Save attacker.html.
  • Return to the attacker page in Microsoft Edge.
  • Refresh the page.

Before you test the fetch, do you expect the attacker JavaScript to receive the balance?

  • Click Try reading account with fetch.

The status begins with Blocked by CORS:. The browser sent the request but refused to expose the response to attacker JavaScript.

  • Return to Microsoft Edge DevTools from earlier.
  • Select the Network tab.
  • Select the attacker page's /account request.
  • Inspect the Headers panel for its attacker origin.
  • Inspect the Response panel for the blocked response access.

What has CORS blocked?

The account API does not grant http://localhost:5000 permission to read its response. CORS protects the response boundary visible to browser JavaScript.

The Network evidence still records that a request reached the API. The next test separates response access from request execution.

Fetch unexpectedly shows the balance?

Confirm that the attacker page uses http://localhost:5000 and the dashboard uses http://localhost:3000. Check that applyCors only matches TRUSTED_ORIGIN.

Ask for help checking the CORS boundary:

Submit the forged transfer

The API still needs a deliberately vulnerable transfer route. Its feature flag stays off so the browser can demonstrate the forged state change before any CSRF defense exists.

  • Return to the terminal from earlier.
  • Press Ctrl+C to stop the running lab.
  • In server.js, add this feature flag below SESSION_ID:
const CSRF_PROTECTION_ENABLED = false;

Why is protection disabled?

The false value preserves the vulnerable baseline. Every learner sees the forged form succeed before the protection is introduced.

  • In the /account route, replace sendJson(res, 200, { balance }); with this response:
    sendJson(res, 200, {
      balance,
      protectionEnabled: CSRF_PROTECTION_ENABLED,
    });

What changes in the account response?

The response now reports the balance plus the current protection state. This makes the vulnerable configuration observable from the trusted client.

  • Save server.js.
  • Restart the updated lab by running this command:
node server.js

What does this restart apply?

The restart loads CSRF_PROTECTION_ENABLED into the API process. It also resets the in-memory balance to 100.

  • Return to the trusted dashboard at http://localhost:3000.
  • Click Start session / Refresh account.
  • Inspect the new /account response in the Response panel.

You should see protectionEnabled set to false beside the balance of 100.

  • In trusted.html, add this disabled button after the account refresh button:
    <button id="transfer" disabled>Transfer $10</button>

Why start with a disabled button?

The trusted transfer control stays disabled until the dashboard has successfully loaded the account. This keeps the visible interface aligned with the session state.

  • In the trusted page's script, add this element reference below statusElement:
    const transferButton = document.querySelector('#transfer');

What does this reference hold?

The transferButton reference lets the account-loading code enable the trusted transfer control after a session starts.

  • Save trusted.html.
  • Refresh the trusted dashboard.

You should see a disabled Transfer $10 button beside the account refresh control.

  • Replace the existing showAccount function with this version:
    function showAccount(data) {
      balanceElement.textContent = `Balance: $${data.balance}`;
      statusElement.textContent = `CSRF protection enabled: ${data.protectionEnabled}`;
    }

What does the dashboard reveal?

The status area now displays the API's protection flag. Seeing false confirms that this run preserves the vulnerable behavior.

  • In loadAccount, add this line immediately before showAccount(data);:
        transferButton.disabled = false;

When does the button become available?

A successful account response enables the button. The trusted page therefore proves that it has an active session before exposing the transfer control.

  • Save trusted.html.
  • Refresh the trusted dashboard.
  • Click Start session / Refresh account.

You should see CSRF protection enabled: false beneath the $100 balance. The transfer button should now be enabled.

The transfer route needs to decode form-shaped request bodies. Node.js delivers that body in chunks, so the helper collects those chunks before parsing the amount.

  • Return to the terminal from earlier.
  • Press Ctrl+C to stop the lab.
  • In server.js, add this helper below setSessionCookie:
function readFormBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.setEncoding('utf8');
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => {
      resolve(new URLSearchParams(body));
    });
    req.on('error', reject);
  });
}

How is the form body decoded?

  • The body variable collects each incoming text chunk.
  • The end event converts the complete body into URLSearchParams.
  • The returned promise lets the route wait for the complete form submission.
  • Replace the current apiServer declaration with this asynchronous declaration:
const apiServer = createServer(async (req, res) => {

Why is the API callback asynchronous?

The callback must await readFormBody(req) before it validates the submitted amount. The existing account route continues through the same callback.

  • Save server.js.
  • Check the updated syntax by running this command:
node server.js

What does this syntax check prove?

The three server addresses confirm that Node.js parsed the new helper and asynchronous callback. The process is ready for the transfer branch.

  • Press Ctrl+C to stop the syntax check.
  • In apiServer, add this transfer branch after the /account branch:
  if (req.method === 'POST' && req.url === '/transfer') {
    if (!hasSession(req)) {
      sendJson(res, 401, { error: 'Start a session first' });
      return;
    }

    const form = await readFormBody(req);
    const amount = Number(form.get('amount'));
    if (!Number.isInteger(amount) || amount <= 0 || amount > balance) {
      sendJson(res, 400, { error: 'Enter a valid whole-dollar amount' });
      return;
    }

    balance -= amount;
    sendJson(res, 200, {
      message: `Transferred $${amount}`,
      balance,
    });
    return;
  }

Why is this route vulnerable?

  • The route checks for a valid session cookie before processing the transfer.
  • The submitted whole-dollar amount is deducted from the in-memory balance.
  • The route has no second value that proves the trusted dashboard initiated the request.
  • Save server.js.

✔️ Awesome, I've got everything!

Great. Save all three files before running the designed obstacle.

ⓧ I'd like to double check the full code

Compare your files with these complete versions.

const { createServer } = require('node:http');
const { randomBytes } = require('node:crypto');
const { readFile } = require('node:fs/promises');

const TRUSTED_PORT = 3000;
const API_PORT = 8000;
const ATTACKER_PORT = 5000;
const TRUSTED_ORIGIN = `http://localhost:${TRUSTED_PORT}`;
const SESSION_COOKIE_NAME = 'session';
const SESSION_ID = randomBytes(16).toString('hex');
const CSRF_PROTECTION_ENABLED = false;

let balance = 100;

function sendJson(res, statusCode, payload) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
  });
  res.end(JSON.stringify(payload));
}

function sendText(res, statusCode, text) {
  res.writeHead(statusCode, {
    'Content-Type': 'text/plain; charset=utf-8',
  });
  res.end(text);
}

function applyCors(req, res) {
  if (req.headers.origin === TRUSTED_ORIGIN) {
    res.setHeader('Access-Control-Allow-Origin', TRUSTED_ORIGIN);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
}

function hasSession(req) {
  const cookieHeader = req.headers.cookie ?? '';
  return cookieHeader
    .split(';')
    .some((part) => part.trim() === `${SESSION_COOKIE_NAME}=${SESSION_ID}`);
}

function setSessionCookie(res) {
  res.setHeader(
    'Set-Cookie',
    `${SESSION_COOKIE_NAME}=${SESSION_ID}; HttpOnly; SameSite=Strict; Path=/`,
  );
}

function readFormBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.setEncoding('utf8');
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => {
      resolve(new URLSearchParams(body));
    });
    req.on('error', reject);
  });
}

async function serveHtml(res, fileName) {
  try {
    const html = await readFile(fileName, { encoding: 'utf8' });
    res.writeHead(200, {
      'Content-Type': 'text/html; charset=utf-8',
    });
    res.end(html);
  } catch {
    sendText(res, 500, `Could not read ${fileName}`);
  }
}

const apiServer = createServer(async (req, res) => {
  applyCors(req, res);

  if (req.method === 'GET' && req.url === '/account') {
    if (!hasSession(req)) {
      setSessionCookie(res);
    }

    sendJson(res, 200, {
      balance,
      protectionEnabled: CSRF_PROTECTION_ENABLED,
    });
    return;
  }

  if (req.method === 'POST' && req.url === '/transfer') {
    if (!hasSession(req)) {
      sendJson(res, 401, { error: 'Start a session first' });
      return;
    }

    const form = await readFormBody(req);
    const amount = Number(form.get('amount'));
    if (!Number.isInteger(amount) || amount <= 0 || amount > balance) {
      sendJson(res, 400, { error: 'Enter a valid whole-dollar amount' });
      return;
    }

    balance -= amount;
    sendJson(res, 200, {
      message: `Transferred $${amount}`,
      balance,
    });
    return;
  }

  sendJson(res, 404, { error: 'Not found' });
});

const trustedServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    sendText(res, 200, 'Security lab ready');
    return;
  }

  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'trusted.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

const attackerServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'attacker.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

apiServer.listen(API_PORT, () => {
  console.log(`Account API: http://localhost:${API_PORT}`);
});

trustedServer.listen(TRUSTED_PORT, () => {
  console.log(`Trusted dashboard: http://localhost:${TRUSTED_PORT}`);
});

attackerServer.listen(ATTACKER_PORT, () => {
  console.log(`Attacker page: http://localhost:${ATTACKER_PORT}`);
});
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Trusted Account Dashboard</title>
</head>
<body>
  <main>
    <h1>Trusted Account Dashboard</h1>
    <p>This page is the only frontend origin allowed to read the account API.</p>
    <div id="balance">Balance not loaded</div>
    <button id="loadAccount">Start session / Refresh account</button>
    <button id="transfer" disabled>Transfer $10</button>
    <pre id="status">Ready.</pre>
  </main>

  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const balanceElement = document.querySelector('#balance');
    const statusElement = document.querySelector('#status');
    const transferButton = document.querySelector('#transfer');

    function showAccount(data) {
      balanceElement.textContent = `Balance: $${data.balance}`;
      statusElement.textContent = `CSRF protection enabled: ${data.protectionEnabled}`;
    }

    async function loadAccount() {
      statusElement.textContent = 'Loading account...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error ?? `Request failed with ${response.status}`);
        }

        transferButton.disabled = false;
        showAccount(data);
      } catch (error) {
        statusElement.textContent = error.message;
      }
    }

    document.querySelector('#loadAccount').addEventListener('click', loadAccount);
  </script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Attacker Test Page</title>
</head>
<body>
  <main>
    <h1>Attacker Test Page</h1>
    <p>First try to read the API response. Then try to submit a form-shaped state change.</p>

    <button id="readAccount">Try reading account with fetch</button>

    <form id="csrfForm" method="post" target="attackResult">
      <input type="hidden" name="amount" value="25">
      <button type="submit">Submit forged $25 transfer</button>
    </form>

    <iframe name="attackResult" title="Hidden attack result" hidden></iframe>
    <pre id="status">Ready.</pre>
  </main>

  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const statusElement = document.querySelector('#status');
    const csrfForm = document.querySelector('#csrfForm');

    csrfForm.action = `${API_ORIGIN}/transfer`;

    async function tryReadingAccount() {
      statusElement.textContent = 'Trying cross-origin fetch...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();
        statusElement.textContent = `Unexpectedly read balance: $${data.balance}`;
      } catch (error) {
        statusElement.textContent = `Blocked by CORS: ${error.message}`;
      }
    }

    csrfForm.addEventListener('submit', () => {
      statusElement.textContent =
        'Forged form submitted. Refresh the trusted dashboard to check whether state changed.';
    });

    document.querySelector('#readAccount').addEventListener('click', tryReadingAccount);
  </script>
</body>
</html>

Before you run the complete test, do you expect a CORS-blocked attacker page to change the authenticated balance?

  • Start the completed vulnerable lab by running this command:
node server.js

What does this final run reset?

The fresh process starts the balance at 100 and creates a new local session identifier. You can now compare both attacker requests against the same vulnerable run.

  • Return to http://localhost:3000.
  • Refresh the trusted dashboard.
  • Click Start session / Refresh account.

Confirm that the trusted dashboard shows Balance: $100 before continuing.

  • Return to http://localhost:5000.
  • Refresh the attacker page.
  • Click Try reading account with fetch.

The attacker status begins with Blocked by CORS: again. Its JavaScript still cannot read the account response.

Before you submit the form, predict whether the session cookie will let the API execute the transfer.

  • Click Submit forged $25 transfer.
  • Select the new /transfer request in the Network log.
  • Confirm that the request completed with status 200.
  • Return to the trusted dashboard.
  • Click Start session / Refresh account.

You should now see Balance: $75. The intended obstacle is complete: CORS hid one response from attacker JavaScript while the form-shaped request still changed authenticated state.

Why did the forged transfer work?

The browser automatically attached the valid session cookie to the form submission. The API saw an authenticated request with a valid amount.

The server had no request value that only the trusted dashboard could supply. The cookie proved session membership while leaving user intent unproven.

Balance still shows $100?

Confirm that CSRF_PROTECTION_ENABLED is false. Make sure you loaded the trusted dashboard first so the API could set the session cookie.

Check that the form action ends in /transfer and its hidden amount is 25.

Ask for help tracing the request:

You have exposed the exact gap this lab was built to reveal. Next, you will add a server-generated token that lets the trusted transfer succeed while rejecting the forged form.

Block the Forgery with a Synchronizer Token

Your attacker page exposed the gap between CORS and CSRF. CORS blocked response access. The forged form still changed the cookie-authenticated balance.

A session cookie proves that the browser has a session. A server-stored synchronizer token supplies separate proof that the trusted page initiated the transfer.

In this step, get ready to:
  • Add a server-stored synchronizer token.
  • Send the token from the trusted dashboard.
  • Prove the new request boundary in Microsoft Edge DevTools.
Protect the API with a server-stored token

The API generates one unpredictable token for its in-memory session. Every protected transfer must return that token through the trusted client.

Why use a synchronizer token?

The lab already stores session state on the server. The synchronizer-token pattern fits this stateful design because the server can compare each submitted token with its own session value.

The attacker page cannot read the token from the credentialed account response. Its forged form therefore reaches the API without the required proof of intent.

  • Stop the running server with Ctrl+C if the process is still active.
  • In server.js, find these session constants:
const SESSION_ID = randomBytes(16).toString('hex');
const CSRF_PROTECTION_ENABLED = false;

What does this code show?

The session already has a random identifier. The protection flag still preserves the intentionally vulnerable state from the previous step.

  • Replace those constants with the token generation and enabled protection state below:
const SESSION_ID = randomBytes(16).toString('hex');
const CSRF_TOKEN = randomBytes(32).toString('hex');
const CSRF_PROTECTION_ENABLED = true;

What changed?

  • CSRF_TOKEN stores a fresh server-generated value whenever the lab starts.
  • CSRF_PROTECTION_ENABLED activates the checks you add next.
  • Save server.js.
  • Start the updated lab by running this command:
node server.js

What does this command prove?

Node.js loads the new constants without a syntax error. The three local server addresses appear in Windows PowerShell when startup succeeds.

  • Return to the trusted dashboard from earlier.
  • Refresh http://localhost:3000.
  • Select Start session / Refresh account.

You will see CSRF protection enabled: true in the status area. This confirms that the server loaded the protected configuration.

Server fails after the constant change?

Check that CSRF_TOKEN appears after SESSION_ID. Confirm that both calls use the existing randomBytes import.

Ask for help with the exact startup output if the process exits. Help me debug the token constants in server.js.

  • Stop the server with Ctrl+C.
  • In server.js, add this helper below setSessionCookie:
function hasValidCsrfToken(req) {
  return req.headers['x-csrf-token'] === CSRF_TOKEN;
}

How does the comparison work?

Node.js exposes incoming header names in lowercase. The helper compares the submitted X-CSRF-Token value with the token held by the server.

  • Find the start of the POST /transfer handler:
  if (req.method === 'POST' && req.url === '/transfer') {
    if (!hasSession(req)) {
      sendJson(res, 401, { error: 'Start a session first' });
      return;
    }

    const form = await readFormBody(req);

Where is the vulnerability?

The existing handler checks the session cookie before reading the transfer amount. Any request carrying that cookie can reach the balance-changing logic.

  • Replace that opening section with the protected validation flow below:
  if (req.method === 'POST' && req.url === '/transfer') {
    if (!hasSession(req)) {
      sendJson(res, 401, { error: 'Start a session first' });
      return;
    }

    if (CSRF_PROTECTION_ENABLED && !hasValidCsrfToken(req)) {
      sendJson(res, 403, { error: 'Invalid or missing CSRF token' });
      return;
    }

    let form;
    try {
      form = await readFormBody(req);
    } catch {
      sendJson(res, 400, { error: 'Could not read request body' });
      return;
    }

What does the protected flow do?

  • The session check still rejects requests without a valid session.
  • The token check returns status 403 before the transfer body can change the balance.
  • The guarded body read returns status 400 if the form data cannot be parsed.
  • Save server.js.
  • Restart the lab by running this command:
node server.js

What does this restart change?

The in-memory balance resets to $100. The new session receives a fresh session identifier and a fresh CSRF token.

  • Return to http://localhost:3000.
  • Select Start session / Refresh account.
  • Switch back to the attacker page from earlier.

Before you submit the forged form, do you expect its cookie-backed request to pass the new token check?

  • Select Submit forged $25 transfer.
  • Return to the trusted dashboard.
  • Select Start session / Refresh account.

The balance remains $100. The missing token stopped the forged transfer before the amount reached the balance logic.

That closes the exact gap you exposed earlier. The attacker can still send a request, but the API now refuses to perform the state change.

  • Stop the server with Ctrl+C.
  • In server.js, add this preflight handler immediately after applyCors(req, res);:
  if (req.method === 'OPTIONS') {
    res.writeHead(204, {
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, X-CSRF-Token',
    });
    res.end();
    return;
  }

Why handle preflight?

The custom CSRF header makes the trusted transfer a non-simple cross-origin request. The browser sends an OPTIONS preflight before the protected POST.

This response permits the transfer method and the two request headers used by the trusted dashboard.

  • Find the account response payload:
    sendJson(res, 200, {
      balance,
      protectionEnabled: CSRF_PROTECTION_ENABLED,
    });

What does the account return now?

The current response returns the balance and protection state. The trusted page still needs the server-generated token.

  • Replace the payload with this token-bearing response:
    sendJson(res, 200, {
      balance,
      csrfToken: CSRF_TOKEN,
      protectionEnabled: CSRF_PROTECTION_ENABLED,
    });

How does the trusted page receive the token?

The account JSON now carries csrfToken. The trusted origin can read that credentialed response because it is present in the API's CORS allowlist.

  • Save server.js.
  • Restart the lab by running this command:
node server.js

What is ready now?

The API can issue the token through /account. It can also authorize the custom header during preflight.

  • Return to the trusted dashboard.
  • Select Start session / Refresh account.
  • Return to Microsoft Edge DevTools from earlier.
  • Select the Network tab.
  • Select the /account request.
  • Select the Response panel.

You will see a non-empty csrfToken field beside balance and protectionEnabled. Keep this token out of screenshots because it represents the session's CSRF proof.

✔️ Awesome, I've got everything!

Your API now generates the synchronizer token. It returns the token to the trusted origin and rejects transfers that omit it.

ⓧ I'd like to double check the full code

const { createServer } = require('node:http');
const { randomBytes } = require('node:crypto');
const { readFile } = require('node:fs/promises');

const TRUSTED_PORT = 3000;
const API_PORT = 8000;
const ATTACKER_PORT = 5000;
const TRUSTED_ORIGIN = `http://localhost:${TRUSTED_PORT}`;
const SESSION_COOKIE_NAME = 'session';
const SESSION_ID = randomBytes(16).toString('hex');
const CSRF_TOKEN = randomBytes(32).toString('hex');
const CSRF_PROTECTION_ENABLED = true;

let balance = 100;

function sendJson(res, statusCode, payload) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
  });
  res.end(JSON.stringify(payload));
}

function sendText(res, statusCode, text) {
  res.writeHead(statusCode, {
    'Content-Type': 'text/plain; charset=utf-8',
  });
  res.end(text);
}

function applyCors(req, res) {
  if (req.headers.origin === TRUSTED_ORIGIN) {
    res.setHeader('Access-Control-Allow-Origin', TRUSTED_ORIGIN);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
}

function hasSession(req) {
  const cookieHeader = req.headers.cookie ?? '';
  return cookieHeader
    .split(';')
    .some((part) => part.trim() === `${SESSION_COOKIE_NAME}=${SESSION_ID}`);
}

function setSessionCookie(res) {
  res.setHeader(
    'Set-Cookie',
    `${SESSION_COOKIE_NAME}=${SESSION_ID}; HttpOnly; SameSite=Strict; Path=/`,
  );
}

function hasValidCsrfToken(req) {
  return req.headers['x-csrf-token'] === CSRF_TOKEN;
}

function readFormBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.setEncoding('utf8');
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => {
      resolve(new URLSearchParams(body));
    });
    req.on('error', reject);
  });
}

async function serveHtml(res, fileName) {
  try {
    const html = await readFile(fileName, { encoding: 'utf8' });
    res.writeHead(200, {
      'Content-Type': 'text/html; charset=utf-8',
    });
    res.end(html);
  } catch {
    sendText(res, 500, `Could not read ${fileName}`);
  }
}

const apiServer = createServer(async (req, res) => {
  applyCors(req, res);

  if (req.method === 'OPTIONS') {
    res.writeHead(204, {
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, X-CSRF-Token',
    });
    res.end();
    return;
  }

  if (req.method === 'GET' && req.url === '/account') {
    if (!hasSession(req)) {
      setSessionCookie(res);
    }

    sendJson(res, 200, {
      balance,
      csrfToken: CSRF_TOKEN,
      protectionEnabled: CSRF_PROTECTION_ENABLED,
    });
    return;
  }

  if (req.method === 'POST' && req.url === '/transfer') {
    if (!hasSession(req)) {
      sendJson(res, 401, { error: 'Start a session first' });
      return;
    }

    if (CSRF_PROTECTION_ENABLED && !hasValidCsrfToken(req)) {
      sendJson(res, 403, { error: 'Invalid or missing CSRF token' });
      return;
    }

    let form;
    try {
      form = await readFormBody(req);
    } catch {
      sendJson(res, 400, { error: 'Could not read request body' });
      return;
    }

    const amount = Number(form.get('amount'));
    if (!Number.isInteger(amount) || amount <= 0 || amount > balance) {
      sendJson(res, 400, { error: 'Enter a valid whole-dollar amount' });
      return;
    }

    balance -= amount;
    sendJson(res, 200, {
      message: `Transferred $${amount}`,
      balance,
    });
    return;
  }

  sendJson(res, 404, { error: 'Not found' });
});

const trustedServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    sendText(res, 200, 'Security lab ready');
    return;
  }

  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'trusted.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

const attackerServer = createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    await serveHtml(res, 'attacker.html');
    return;
  }

  sendText(res, 404, 'Not found');
});

apiServer.listen(API_PORT, () => {
  console.log(`Account API: http://localhost:${API_PORT}`);
});

trustedServer.listen(TRUSTED_PORT, () => {
  console.log(`Trusted dashboard: http://localhost:${TRUSTED_PORT}`);
});

attackerServer.listen(ATTACKER_PORT, () => {
  console.log(`Attacker page: http://localhost:${ATTACKER_PORT}`);
});

What should match?

Use this reference to compare the complete server file. The token generation, preflight response, account payload, transfer check, and guarded form reader should all match.

Complete the trusted transfer flow

The trusted dashboard can now read the session token. Its transfer function must retain that value and send it through the custom request header.

  • In trusted.html, find the final DOM reference near the top of the script:
    const transferButton = document.querySelector('#transfer');

What does this reference hold?

The existing variable points to the disabled transfer button. The script needs a separate value for the token returned by the API.

  • Replace that line with the button reference and token state below:
    const transferButton = document.querySelector('#transfer');
    let csrfToken = '';

Why start with an empty token?

The empty value prevents a transfer before the account request succeeds. The trusted account response supplies the real session token.

  • Find the successful end of loadAccount:
        transferButton.disabled = false;
        showAccount(data);

What happens after loading?

The current client enables the transfer button and renders the account. It does not retain the returned token.

  • Replace those lines with the token assignment below:
        csrfToken = data.csrfToken;
        transferButton.disabled = false;
        showAccount(data);

Where does the token live?

The trusted script keeps the token in memory. Each successful account refresh replaces the current value with the token returned by the API.

  • Save trusted.html.
  • Refresh the trusted dashboard.
  • Select Start session / Refresh account.
  • Evaluate the retained variable in the browser's JavaScript console:
csrfToken

What should the console show?

You will see a non-empty token value. This confirms that the trusted script retained the token from the account response.

  • In trusted.html, add this function below loadAccount:
    async function transferMoney() {
      if (!csrfToken) {
        statusElement.textContent = 'Load the account before transferring.';
        return;
      }

      statusElement.textContent = 'Submitting trusted transfer...';

      try {
        const response = await fetch(`${API_ORIGIN}/transfer`, {
          method: 'POST',
          credentials: 'include',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'X-CSRF-Token': csrfToken,
          },
          body: new URLSearchParams({ amount: '10' }),
        });
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error ?? `Request failed with ${response.status}`);
        }

        balanceElement.textContent = `Balance: $${data.balance}`;
        statusElement.textContent = data.message;
      } catch (error) {
        statusElement.textContent = error.message;
      }
    }

What does the trusted transfer do?

  • The early check stops the request when no token has been loaded.
  • The credentialed request includes the session cookie.
  • The custom X-CSRF-Token header carries the server-issued token.
  • The form body requests a $10 transfer.
  • Find the existing account button listener at the bottom of the script:
    document.querySelector('#loadAccount').addEventListener('click', loadAccount);

Which control works now?

The current listener connects only the account button. The transfer button still needs its click handler.

  • Add the transfer listener beneath the existing line:
    document.querySelector('#loadAccount').addEventListener('click', loadAccount);
    transferButton.addEventListener('click', transferMoney);

What does the second listener connect?

The enabled transfer button now runs transferMoney. That function supplies the token required by the server.

  • Save trusted.html.
  • Refresh the trusted dashboard.
  • Select Start session / Refresh account.
  • Select Transfer $10.

You will see Balance: $90 and Transferred $10. The token-bearing request passed the server check.

Trusted transfer rejected?

Confirm that you selected Start session / Refresh account after the most recent server restart. A restart replaces both in-memory identifiers.

Check that the header uses X-CSRF-Token and the value uses csrfToken.

Ask for help with the response status if the transfer still fails. Help me debug my protected trusted transfer.

A clearer dashboard makes the successful balance change easier to compare with the attacker result. The next edits add the final visual treatment without changing the security flow.

  • In trusted.html, add this initial style block before </head>:
  <style>
    body {
      max-width: 720px;
      margin: 48px auto;
      padding: 0 20px;
      font-family: system-ui, sans-serif;
      color: #172033;
      background: #f5f7fb;
    }

    main {
      padding: 28px;
      border: 1px solid #cfd7e6;
      border-radius: 14px;
      background: white;
      box-shadow: 0 12px 30px rgb(27 39 68 / 10%);
    }
  </style>

What does the page frame add?

The body rules center the content on a light background. The main rules turn the dashboard into a bordered card.

  • Insert these balance and button rules before </style>:
    .balance {
      margin: 20px 0;
      font-size: 2.5rem;
      font-weight: 700;
    }

    button {
      margin: 6px 8px 6px 0;
      padding: 10px 14px;
      border: 0;
      border-radius: 8px;
      color: white;
      background: #2457d6;
      cursor: pointer;
    }

What becomes easier to scan?

The balance gains visual weight. The blue controls clearly separate account actions from the surrounding text.

  • Save trusted.html.
  • Refresh the trusted dashboard.

You will see the account controls inside a centered white card. The buttons now use a blue background.

  • Find the balance element in trusted.html:
    <div id="balance">Balance not loaded</div>

Why is the balance style missing?

The CSS selector expects a balance class. The current element has only its existing ID.

  • Replace the element with this class-bearing version:
    <div id="balance" class="balance">Balance not loaded</div>

What does the class connect?

The class applies the large balance treatment. The ID continues to support the existing JavaScript reference.

  • Insert these final interaction styles before </style>:
    button:disabled {
      cursor: not-allowed;
      opacity: 0.5;
    }

    pre {
      min-height: 72px;
      padding: 12px;
      overflow-wrap: anywhere;
      white-space: pre-wrap;
      border-radius: 8px;
      background: #edf2ff;
    }

What do the final states show?

The disabled transfer button becomes visibly muted before a token loads. The status output gains its own readable panel.

  • Save trusted.html.
  • Refresh the trusted dashboard.

You will see a large balance value and a pale blue status panel. The transfer button starts muted until you load the account.

✔️ Awesome, I've got everything!

Your trusted dashboard retains the token and submits protected transfers. Its visual states now make the test sequence clear.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Trusted Account Dashboard</title>
  <style>
    body {
      max-width: 720px;
      margin: 48px auto;
      padding: 0 20px;
      font-family: system-ui, sans-serif;
      color: #172033;
      background: #f5f7fb;
    }

    main {
      padding: 28px;
      border: 1px solid #cfd7e6;
      border-radius: 14px;
      background: white;
      box-shadow: 0 12px 30px rgb(27 39 68 / 10%);
    }

    .balance {
      margin: 20px 0;
      font-size: 2.5rem;
      font-weight: 700;
    }

    button {
      margin: 6px 8px 6px 0;
      padding: 10px 14px;
      border: 0;
      border-radius: 8px;
      color: white;
      background: #2457d6;
      cursor: pointer;
    }

    button:disabled {
      cursor: not-allowed;
      opacity: 0.5;
    }

    pre {
      min-height: 72px;
      padding: 12px;
      overflow-wrap: anywhere;
      white-space: pre-wrap;
      border-radius: 8px;
      background: #edf2ff;
    }
  </style>
</head>
<body>
  <main>
    <h1>Trusted Account Dashboard</h1>
    <p>This page is the only frontend origin allowed to read the account API.</p>
    <div id="balance" class="balance">Balance not loaded</div>
    <button id="loadAccount">Start session / Refresh account</button>
    <button id="transfer" disabled>Transfer $10</button>
    <pre id="status">Ready.</pre>
  </main>

  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const balanceElement = document.querySelector('#balance');
    const statusElement = document.querySelector('#status');
    const transferButton = document.querySelector('#transfer');
    let csrfToken = '';

    function showAccount(data) {
      balanceElement.textContent = `Balance: $${data.balance}`;
      statusElement.textContent = `CSRF protection enabled: ${data.protectionEnabled}`;
    }

    async function loadAccount() {
      statusElement.textContent = 'Loading account...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error ?? `Request failed with ${response.status}`);
        }

        csrfToken = data.csrfToken;
        transferButton.disabled = false;
        showAccount(data);
      } catch (error) {
        statusElement.textContent = error.message;
      }
    }

    async function transferMoney() {
      if (!csrfToken) {
        statusElement.textContent = 'Load the account before transferring.';
        return;
      }

      statusElement.textContent = 'Submitting trusted transfer...';

      try {
        const response = await fetch(`${API_ORIGIN}/transfer`, {
          method: 'POST',
          credentials: 'include',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'X-CSRF-Token': csrfToken,
          },
          body: new URLSearchParams({ amount: '10' }),
        });
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error ?? `Request failed with ${response.status}`);
        }

        balanceElement.textContent = `Balance: $${data.balance}`;
        statusElement.textContent = data.message;
      } catch (error) {
        statusElement.textContent = error.message;
      }
    }

    document.querySelector('#loadAccount').addEventListener('click', loadAccount);
    transferButton.addEventListener('click', transferMoney);
  </script>
</body>
</html>

What should match?

Use this reference to compare the complete trusted page. Check the token state, protected transfer function, click listener, balance class, and style block.

Restart and prove both outcomes

The attacker logic remains unchanged because a forged form cannot supply the server-issued token. Its final styling keeps the hostile test visually distinct from the trusted dashboard.

  • In attacker.html, add this initial style block before </head>:
  <style>
    body {
      max-width: 720px;
      margin: 48px auto;
      padding: 0 20px;
      font-family: system-ui, sans-serif;
      color: #351717;
      background: #fff4f4;
    }

    main {
      padding: 28px;
      border: 1px solid #e4b8b8;
      border-radius: 14px;
      background: white;
      box-shadow: 0 12px 30px rgb(68 27 27 / 10%);
    }
  </style>

What does the attacker frame show?

The pale red background separates the attacker origin from the trusted dashboard. The bordered card keeps both attack tests together.

  • Insert these control and status styles before </style>:
    button {
      margin: 6px 8px 6px 0;
      padding: 10px 14px;
      border: 0;
      border-radius: 8px;
      color: white;
      background: #b3261e;
      cursor: pointer;
    }

    pre {
      min-height: 72px;
      padding: 12px;
      overflow-wrap: anywhere;
      white-space: pre-wrap;
      border-radius: 8px;
      background: #fff0f0;
    }

What becomes visible?

The red buttons mark attacker actions. The status panel keeps the CORS result and forged-form message easy to read.

  • Save attacker.html.
  • Refresh the attacker page.

You will see the attacker tests inside a pale red page with dark red controls. The existing fetch and form behavior stays unchanged.

✔️ Awesome, I've got everything!

Your attacker page retains both security tests. Its final styling clearly separates it from the trusted origin.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Attacker Test Page</title>
  <style>
    body {
      max-width: 720px;
      margin: 48px auto;
      padding: 0 20px;
      font-family: system-ui, sans-serif;
      color: #351717;
      background: #fff4f4;
    }

    main {
      padding: 28px;
      border: 1px solid #e4b8b8;
      border-radius: 14px;
      background: white;
      box-shadow: 0 12px 30px rgb(68 27 27 / 10%);
    }

    button {
      margin: 6px 8px 6px 0;
      padding: 10px 14px;
      border: 0;
      border-radius: 8px;
      color: white;
      background: #b3261e;
      cursor: pointer;
    }

    pre {
      min-height: 72px;
      padding: 12px;
      overflow-wrap: anywhere;
      white-space: pre-wrap;
      border-radius: 8px;
      background: #fff0f0;
    }
  </style>
</head>
<body>
  <main>
    <h1>Attacker Test Page</h1>
    <p>First try to read the API response. Then try to submit a form-shaped state change.</p>

    <button id="readAccount">Try reading account with fetch</button>

    <form id="csrfForm" method="post" target="attackResult">
      <input type="hidden" name="amount" value="25">
      <button type="submit">Submit forged $25 transfer</button>
    </form>

    <iframe name="attackResult" title="Hidden attack result" hidden></iframe>
    <pre id="status">Ready.</pre>
  </main>

  <script>
    const API_ORIGIN = `${window.location.protocol}//${window.location.hostname}:8000`;
    const statusElement = document.querySelector('#status');
    const csrfForm = document.querySelector('#csrfForm');

    csrfForm.action = `${API_ORIGIN}/transfer`;

    async function tryReadingAccount() {
      statusElement.textContent = 'Trying cross-origin fetch...';

      try {
        const response = await fetch(`${API_ORIGIN}/account`, {
          credentials: 'include',
        });
        const data = await response.json();
        statusElement.textContent = `Unexpectedly read balance: $${data.balance}`;
      } catch (error) {
        statusElement.textContent = `Blocked by CORS: ${error.message}`;
      }
    }

    csrfForm.addEventListener('submit', () => {
      statusElement.textContent =
        'Forged form submitted. Refresh the trusted dashboard to check whether state changed.';
    });

    document.querySelector('#readAccount').addEventListener('click', tryReadingAccount);
  </script>
</body>
</html>

What should match?

Use this reference to compare the complete attacker page. Its fetch test, hidden-iframe form, status messages, and style block should match.

The final test starts from a clean in-memory balance. It compares one trusted transfer with one forged transfer while Microsoft Edge DevTools records the request sequence.

  • Stop the current server with Ctrl+C.

Before you run the final test, which request do you expect the browser to send before the trusted POST?

  • Restart the completed lab by running this command:
node server.js

What does the clean restart establish?

The balance returns to $100. The server also generates a new session identifier and CSRF token for the final comparison.

  • Return to http://localhost:3000.
  • Refresh the trusted dashboard.
  • Return to Microsoft Edge DevTools from earlier.
  • Select the Network tab.
  • Select Start session / Refresh account.
  • Select Transfer $10.

The dashboard balance changes from $100 to $90. The Network log shows an OPTIONS row followed by a POST row.

  • Select the trusted OPTIONS request.
  • Select the Headers panel.
  • Confirm that the preflight requests permission for the custom CSRF header.
  • Select the trusted POST request.
  • Select the Response panel.

The trusted response reports Transferred $10 with a balance of $90. The preflight allowed the token-bearing request to proceed.

  • Switch back to http://localhost:5000.
  • Refresh the attacker page.
  • Return to the attacker page's Network tool from earlier.
  • Select Submit forged $25 transfer.
  • Select the new /transfer request.
  • Select the Response panel.

The forged request receives status 403 with Invalid or missing CSRF token. The API rejected it before reading the transfer amount.

  • Return to the trusted dashboard.
  • Select Start session / Refresh account.

The balance stays at $90. You have now proved that the trusted transfer succeeds while the same authenticated browser cannot forge a transfer without the synchronizer token.

Final request sequence looks different?

If the trusted transfer receives 403, reload the account after the latest server restart. This refreshes the browser's session cookie and in-memory token.

If the forged request changes the balance, confirm that CSRF_PROTECTION_ENABLED is true. Check that token validation appears before readFormBody.

Ask for help with the Network evidence if either outcome differs. Help me compare my trusted and attacker transfer requests.

You closed the lab's central vulnerability. The dashboard now proves user intent with a server-issued token while DevTools shows the preflight and rejected forgery.

Secret mission

Add Origin Verification

Add a complete Origin comparison as a second server-side boundary for every transfer. Then prove that the trusted dashboard still works while the attacker request receives a specific rejection.

Clean Up Your Resources

Clean Up Your Resources

Everything in this lab runs locally, so it creates no ongoing cloud or API costs. The options below let you keep the lab active, pause it for later, or remove its files.

Resources you used:

  • The local Node.js process serving http://localhost:3000, http://localhost:8000, and http://localhost:5000.
  • The completed server.js, trusted.html, and attacker.html files in Visual Studio Code.

Keep everything running

No action is needed. This option fits continued testing of the trusted dashboard against the attacker page.

  • Leave the Node.js process running while you continue testing the three local origins.
  • Keep server.js, trusted.html, and attacker.html in the current Visual Studio Code workspace.

Pause - I'll come back to this later

A paused lab releases the local server process while preserving your code for another session.

  • Switch back to the Windows PowerShell terminal from earlier.
  • Press Ctrl+C to stop all three local servers.

You'll see the Windows PowerShell prompt return. This confirms that the local servers have stopped.

What Happens to the Session?

Stopping the process clears the in-memory balance and session. Your three project files remain unchanged.

Delete - I don't want to use this again

Deleting the lab permanently removes these three local files. Your installed tools remain untouched.

  • Switch back to the Windows PowerShell terminal from earlier if the Node.js process is still active.
  • Press Ctrl+C to stop all three local servers.
  • Delete server.js from the Explorer sidebar in Visual Studio Code.
  • Delete trusted.html from the same Explorer sidebar.
  • Delete attacker.html from the same Explorer sidebar.

You'll no longer see any of the three lab files in the Explorer sidebar. The local security lab has now been removed.

Nice Work!

Nice Work!

You made it! You built a three-origin security lab with Node.js. You proved how browser security controls behave by testing trusted requests against a real attacker page.

You've learned how to:

  • Built a credentialed CORS allowlist that lets the trusted dashboard read the cookie-authenticated API while denying the attacker page access to the response.
  • Demonstrated the CORS and CSRF gap by blocking the attacker script from reading account data while its forged form still changed the balance. You inspected both outcomes with Microsoft Edge DevTools.
  • Protected transfers with a synchronizer token sent through a custom request header. You observed the browser send a preflight request before the trusted transfer. You also confirmed that a forged transfer receives a 403 response.
  • Secret Mission: Added complete Origin verification as defense in depth. Trusted transfers continue to succeed while requests from the attacker origin fail with Origin check failed.

Ready to quiz yourself?