Fix an OWASP API Data Leak
Build and secure a Node.js API that prevents sensitive data exposure.
Introduction
30 Second Summary
A dashboard can look polished while quietly sending private details to anyone who checks the data behind it. That hidden gap can expose people even when the screen appears safe.
In this project, you will build a local youth opportunities dashboard backed by a Node.js API. You will secure its public responses after uncovering a hidden data leak.
What You'll Build
You'll demo a polished dashboard whose raw response proves the privacy leak is gone while automated tests guard the fix.
By the end of this project, you'll have:
- A working local dashboard that loads two fictional youth opportunities from your Node.js API.
- A complete OWASP security proof that begins with internalContact and reviewNotes exposed in the raw response. It ends with a server-side allowlist protected by two automated regression tests.
- A public GitHub repository that presents the complete security story as an inspectable portfolio artifact.
- Secret Mission: Enable GitHub CodeQL default setup and review its automated JavaScript analysis.
Are there any prerequisites?
Basic JavaScript knowledge is helpful. Step 1 checks your Windows workspace before any project code is written.
Before We Start
This checkpoint defines the purpose behind the security work you are about to complete. Your fictional youth opportunities API and dashboard will demonstrate how exposing internalContact details or reviewNotes harms privacy even when the browser interface hides those fields.
Prepare the Windows Workspace
A privacy lesson is easier to follow when setup problems stay out of the way. Version drift or files created in the wrong place can distract from the leak you are about to expose.
A trusted Visual Studio Code workspace gives the demo one isolated home on your Desktop. Verified Node.js and Git versions make every later command predictable.
In this step, get ready to:
- Prepare a trusted Windows workspace with an integrated terminal.
- Verify the required development tools.
- Create the empty repository structure on main.
Prepare the trusted workspace
Visual Studio Code can run PowerShell inside the folder you are editing. This keeps the file tree and every setup command tied to the same project location.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
You will see the Visual Studio Code start screen.
- Click File in the top menu bar.
- Click Open Folder.
- Select your Desktop in the Windows folder picker.
- Create the secure-youth-opportunities-api folder with the folder picker's new-folder control.
- Select the new secure-youth-opportunities-api folder.
- Click Select Folder to use it as the workspace.
The trust prompt can feel cautious because Visual Studio Code may run commands from the folder. This folder is safe to trust because you created it empty.
- Confirm that you trust the secure-youth-opportunities-api folder when Visual Studio Code asks.
- Click Terminal in the top menu bar.
- Click New Terminal.
- Open the terminal profile dropdown if the new terminal does not use PowerShell.
- Select PowerShell as the terminal profile.
The PowerShell prompt should now point to the secure-youth-opportunities-api folder on your Desktop.
- Check the installed Node.js version by running this command:
node --version
What does this check show?
The command prints the installed Node.js release. This guide uses v24.22.2 from the Node.js LTS line.
✔️ I see v24.22.2 or later
Your Node.js runtime is ready to run the local API in the next step.
ⓧ I see an older version
The installed runtime is older than the release used by this guide. Updating now keeps the later server behavior consistent.
- Download v24.22.2 LTS from the official Node.js download page.
- Complete the Windows installer with its standard options.
- Return to Visual Studio Code after the installation finishes.
- Close the existing integrated terminal.
- Open a new integrated PowerShell terminal.
- Repeat the Node.js version check from above.
ⓧ Node.js is missing
Windows cannot find the Node.js executable. Installing the pinned LTS release adds the runtime required by this project.
- Download v24.22.2 LTS from the official Node.js download page.
- Complete the Windows installer with its standard options.
- Return to Visual Studio Code after the installation finishes.
- Close the existing integrated terminal.
- Open a new integrated PowerShell terminal.
- Repeat the Node.js version check from above.
- Check the installed Git version by running this command:
git --version
What does this check show?
The command prints the installed Git release. Version 2.28.0 or later supports the primary repository initialization command used in this step.
✔️ I see version 2.28.0 or later
Git is ready to initialize the repository directly on the required branch.
ⓧ I see an older version
The installed Git release is below the project requirement. Updating it gives you the initialization behavior used on the main path.
- Download the standalone Windows installer from the official Git for Windows page.
- Complete the Git installer with its standard options.
- Return to Visual Studio Code after the installation finishes.
- Close the existing integrated terminal.
- Open a new integrated PowerShell terminal.
- Repeat the Git version check from above.
ⓧ Git is missing
Windows cannot find Git yet. The official Windows installer adds the command-line tools needed for the local repository.
- Download the standalone Windows installer from the official Git for Windows page.
- Complete the Git installer with its standard options.
- Return to Visual Studio Code after the installation finishes.
- Close the existing integrated terminal.
- Open a new integrated PowerShell terminal.
- Repeat the Git version check from above.
Versions still look unchanged?
Close every integrated terminal after installing or updating a tool. A newly opened terminal reloads the Windows command environment.
Still blocked? Help me troubleshoot why Visual Studio Code cannot see my Node.js or Git installation.
Create the empty project structure
The project separates browser assets from automated security tests. Empty files give each part of the upcoming API a clear home.
- Select the Explorer icon in the left Activity Bar.
- Create a public folder inside secure-youth-opportunities-api with the New Folder button.
- Create a test folder inside secure-youth-opportunities-api with the New Folder button.
You should now see the public and test folders in the Explorer tree.
- Create server.js inside secure-youth-opportunities-api with the New File button.
- Create opportunities.js inside secure-youth-opportunities-api with the New File button.
Both JavaScript files should now appear at the top level of the Explorer tree.
- Create README.md inside secure-youth-opportunities-api with the New File button.
- Create public/index.html with the New File button in the public folder.
The Explorer tree should show README.md at the top level. The public folder should contain index.html.
- Create test/security.test.js with the New File button in the test folder.
Good progress. Your blank project skeleton now keeps browser files separate from security tests.
✔️ Awesome, I've got everything!
Great. Confirm that every target file exists and remains empty.
ⓧ I'd like to double check the full code
Every target file is intentionally empty at this point. Compare your Explorer tree with this checklist.
- The top-level README.md file exists and is empty.
- The top-level opportunities.js file exists and is empty.
- The public/index.html file exists and is empty.
- The top-level server.js file exists and is empty.
- The test/security.test.js file exists and is empty.
Missing a file or folder?
Expand the public and test folders in the Explorer sidebar. Check every filename for the exact spelling shown above.
Need another pair of eyes? Help me compare my project tree with the required structure.
Initialize the repository on main
A local repository records the vulnerable version before you repair it. Starting on main also keeps the local branch aligned with the public repository you create later.
Use the initialization path that matches the Git version you verified earlier.
✔️ Git is version 2.28.0 or later
- Initialize the local repository on main by running this command:
git init -b main
What does this command do?
Git creates its repository metadata inside secure-youth-opportunities-api. It sets the initial branch to main.
ⓧ Git is older than version 2.28.0
The documented fallback creates the repository before pointing HEAD at main.
- Initialize the repository with the official fallback by running this command:
git init && git symbolic-ref HEAD refs/heads/main
What does the fallback do?
The first command creates the repository metadata. The second command points HEAD at the main branch.
Before you run the final check, predict which branch the repository should report and how the empty project files should appear.
- Verify the tool versions and repository state by running these commands:
node --version
git --version
git status
What should I see?
- The Node.js check prints v24.22.2 or a newer compatible release.
- The Git check prints version 2.28.0 or later on the main path.
- The repository status identifies main as the current branch.
- The repository has no commits yet.
- The project files and folders appear as untracked content.
That is the foundation in place. Your trusted workspace now has the verified tools and empty repository structure needed for the security demo.
Repository check failed?
Confirm that the terminal prompt points to the secure-youth-opportunities-api folder. Run the matching initialization command from the version tab again if the repository is missing.
Still blocked? Help me diagnose why Git status is not showing the main branch.
Your Windows workspace is ready. Next, you will build the first API version that makes the privacy gap visible.
Build the Dashboard and Expose the Leak
Your verified workspace gives you a clean foundation. Now the project needs a local Node.js app that turns those empty files into a working dashboard.
A polished interface can conceal private fields inside an API response. This step makes that gap visible before you secure it.
In this step, get ready to:
- Model two fictional opportunities with public properties plus internal properties.
- Serve the dashboard plus its deliberately vulnerable endpoint.
- Compare two polished cards with the raw JSON response that leaks private fields.
Model public and private properties
Each opportunity needs the six fields that visitors can see. It also carries two internal fields that must stay behind the API boundary. All records remain fictional throughout this project.
- Select opportunities.js in the Visual Studio Code Explorer.
- Fill the empty file with the fictional records by pasting this code:
const opportunities = [
{
id: 'opp-001',
title: 'Digital Skills Exchange',
organization: 'Community Youth Lab',
location: 'Tivat, Montenegro',
deadline: '2026-12-15',
summary: 'A fictional peer-led workshop for young people exploring web development.',
internalContact: 'Internal coordinator channel 17',
reviewNotes: 'Demo-only note. Do not expose this field.',
},
{
id: 'opp-002',
title: 'Regional Leadership Sprint',
organization: 'Regional Youth Network',
location: 'Podgorica, Montenegro',
deadline: '2027-01-20',
summary: 'A fictional weekend program focused on teamwork and community projects.',
internalContact: 'Internal coordinator channel 23',
reviewNotes: 'Demo-only note. Verify venue capacity before publishing.',
},
];
module.exports = {
opportunities,
};
What does this data contain?
- The six public properties hold everything the dashboard needs to display.
- The internalContact property represents an internal coordination detail.
- The reviewNotes property represents information used during private review.
- The module.exports object makes the raw array available to the server.
- Save opportunities.js.
- Confirm the Visual Studio Code Explorer shows opportunities.js as a non-empty file.
Good progress. Your API now has realistic demonstration data without using any real participant information.
Seeing a syntax warning?
Check that every opportunity property ends with a comma. Confirm the array closes before module.exports.
Still stuck? Help me compare my opportunities file with the required JavaScript structure.
✔️ Awesome, I've got everything!
Your two fictional records are ready for the local API.
ⓧ I'd like to double check the full code
const opportunities = [
{
id: 'opp-001',
title: 'Digital Skills Exchange',
organization: 'Community Youth Lab',
location: 'Tivat, Montenegro',
deadline: '2026-12-15',
summary: 'A fictional peer-led workshop for young people exploring web development.',
internalContact: 'Internal coordinator channel 17',
reviewNotes: 'Demo-only note. Do not expose this field.',
},
{
id: 'opp-002',
title: 'Regional Leadership Sprint',
organization: 'Regional Youth Network',
location: 'Podgorica, Montenegro',
deadline: '2027-01-20',
summary: 'A fictional weekend program focused on teamwork and community projects.',
internalContact: 'Internal coordinator channel 23',
reviewNotes: 'Demo-only note. Verify venue capacity before publishing.',
},
];
module.exports = {
opportunities,
};
How to use this reference
Compare this reference with your saved opportunities.js file. Every value should match so the later security comparison stays reproducible.
Serve the API and dashboard routes
An HTTP server receives browser requests at specific paths. This server returns opportunity data at one path while serving the dashboard at another.
- Select server.js in the Visual Studio Code Explorer.
- Add the server dependencies plus its reusable response helper by pasting this code:
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { opportunities } = require('./opportunities');
const HOST = '127.0.0.1';
const PORT = 3000;
function send(response, statusCode, contentType, body) {
response.writeHead(statusCode, { 'Content-Type': contentType });
response.end(body);
}
What does this foundation do?
- The three built-in modules provide the web server plus file access plus Windows-safe paths.
- The local HOST plus PORT values define where the browser reaches the app.
- The send() helper gives every response a status code plus a content type plus a body.
- Save server.js.
- Confirm the file begins with four require() statements.
- Create the runnable server shell below send() by pasting this code:
const server = http.createServer((request, response) => {
send(
response,
404,
'application/json; charset=utf-8',
JSON.stringify({ error: 'Not found.' }),
);
});
server.listen(PORT, HOST, () => {
console.log(`Youth Opportunities API running at http://${HOST}:${PORT}`);
});
How does the server shell work?
The request listener runs whenever the browser contacts the server. Its temporary fallback completes every unmatched request with a structured response.
The listen() call binds the server to the local address. Its callback logs the exact URL after the server starts.
- Save server.js.
Before you start the server, what message do you expect the terminal to show when the local address is ready?
- Start the server from the existing PowerShell terminal by running:
node server.js
What does this command do?
This asks Node.js to execute server.js from the current workspace. The process stays active so it can receive browser requests.
You should see Youth Opportunities API running at http://127.0.0.1:3000 in PowerShell. That message proves the local server is listening.
- Stop the temporary server by pressing Ctrl+C in PowerShell.
- Place the API route inside the createServer() callback above the fallback response by pasting this code:
if (request.method === 'GET' && request.url === '/api/opportunities') {
send(
response,
200,
'application/json; charset=utf-8',
JSON.stringify(opportunities),
);
return;
}
What does the API route return?
The condition matches a browser request for /api/opportunities. The response serializes the entire raw opportunities array.
This whole-object serialization creates the privacy problem you will observe after the dashboard is ready.
- Save server.js.
- Restart the server from PowerShell by running:
node server.js
What does this run confirm?
The startup message confirms that the new API route has valid JavaScript syntax. Keep the raw response uninspected until the dashboard is complete.
- Stop the server by pressing Ctrl+C in PowerShell.
- Place the dashboard route below the API route by pasting this code:
if (request.method === 'GET' && request.url === '/') {
const pagePath = path.join(__dirname, 'public', 'index.html');
fs.readFile(pagePath, 'utf8', (error, html) => {
if (error) {
send(
response,
500,
'application/json; charset=utf-8',
JSON.stringify({ error: 'Could not load the dashboard.' }),
);
return;
}
send(response, 200, 'text/html; charset=utf-8', html);
});
return;
}
How does the dashboard route work?
The route builds the path to public/index.html with the operating system's path separator. It reads the file without blocking other requests.
A read failure receives a structured error response. A successful read receives the dashboard as HTML.
- Save server.js.
- Start the completed server from PowerShell by running:
node server.js
What is running now?
The active process can now answer requests for the dashboard plus the opportunity endpoint. Leave this PowerShell process running while you build the page.
You should see the local URL in PowerShell again. The server is ready to serve each change you save in public/index.html.
Server not starting?
Check that PowerShell is still connected to the secure-youth-opportunities-api workspace. Confirm that the previous server process stopped before you started another one.
Need help? Help me diagnose why my local Node.js server does not start.
✔️ Awesome, I've got everything!
Your local server is running with separate routes for the dashboard plus its data.
ⓧ I'd like to double check the full code
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { opportunities } = require('./opportunities');
const HOST = '127.0.0.1';
const PORT = 3000;
function send(response, statusCode, contentType, body) {
response.writeHead(statusCode, { 'Content-Type': contentType });
response.end(body);
}
const server = http.createServer((request, response) => {
if (request.method === 'GET' && request.url === '/api/opportunities') {
send(
response,
200,
'application/json; charset=utf-8',
JSON.stringify(opportunities),
);
return;
}
if (request.method === 'GET' && request.url === '/') {
const pagePath = path.join(__dirname, 'public', 'index.html');
fs.readFile(pagePath, 'utf8', (error, html) => {
if (error) {
send(
response,
500,
'application/json; charset=utf-8',
JSON.stringify({ error: 'Could not load the dashboard.' }),
);
return;
}
send(response, 200, 'text/html; charset=utf-8', html);
});
return;
}
send(
response,
404,
'application/json; charset=utf-8',
JSON.stringify({ error: 'Not found.' }),
);
});
server.listen(PORT, HOST, () => {
console.log(`Youth Opportunities API running at http://${HOST}:${PORT}`);
});
How to use this reference
Compare the route order plus closing brackets with your saved server.js file. The fallback response must remain after both supported routes.
Build and inspect the dashboard
The dashboard uses DOM elements plus textContent to render API values as plain text. Its cards deliberately display only public properties.
- Select public/index.html in the Visual Studio Code Explorer.
- Create the valid page shell plus its base theme by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Youth Opportunities</title>
<style>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #eef3f9;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, #b9d7ff 0, transparent 32rem),
#eef3f9;
}
</style>
</head>
<body>
</body>
</html>
What does the page shell provide?
The document metadata supports character rendering plus responsive browser sizing. The base styles establish consistent sizing plus the pale dashboard background.
- Save public/index.html.
- Open http://127.0.0.1:3000 in your browser.
- Confirm you see a pale page with a blue highlight in the upper-right area.
- Place the visible dashboard structure between the opening plus closing body tags by pasting this code:
<main>
<span class="eyebrow">Secure API portfolio demo</span>
<h1>Youth opportunities worth sharing</h1>
<p class="intro">
A fictional dashboard that demonstrates why privacy controls belong on
the server, not only in the interface.
</p>
<p id="status" aria-live="polite">Loading opportunities...</p>
<section
id="opportunities"
class="grid"
aria-label="Youth opportunities"
></section>
</main>
What will the page display?
The main element holds the introduction plus the live loading status. The empty opportunities section becomes the destination for the cards.
- Save public/index.html.
- Refresh http://127.0.0.1:3000 in your browser.
- Confirm you see the portfolio label plus the main heading plus the loading message.
- Place these layout styles below the body style inside the style element:
main {
width: min(960px, calc(100% - 2rem));
margin: 0 auto;
padding: 4rem 0;
}
.eyebrow {
color: #245bb5;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
max-width: 700px;
margin: 0.75rem 0;
font-size: clamp(2.2rem, 7vw, 4.5rem);
line-height: 0.98;
}
How does this shape the header?
The centered content area stays readable across screen widths. The portfolio label plus responsive heading create a clear visual hierarchy.
- Save public/index.html.
- Refresh the dashboard.
- Confirm the heading is large while the portfolio label appears as compact uppercase text.
- Add the introduction plus status plus card-grid styles below the h1 style by pasting this code:
.intro {
max-width: 650px;
color: #4d5b73;
font-size: 1.1rem;
line-height: 1.7;
}
#status {
margin: 2rem 0 1rem;
color: #245bb5;
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
How does the content adapt?
The introduction stays narrow enough to scan comfortably. The grid automatically chooses the number of card columns that fit the available width.
- Save public/index.html.
- Refresh the dashboard.
- Confirm the introduction is muted while the loading status appears in blue.
- Place a script element below main by pasting this code:
<script>
const status = document.getElementById('status');
const grid = document.getElementById('opportunities');
function addField(card, label, value) {
const line = document.createElement('p');
line.textContent = `${label}: ${value}`;
card.appendChild(line);
}
</script>
Why use textContent?
The two references connect JavaScript to the existing status plus grid elements. The addField() helper creates a paragraph for each card property.
The textContent assignment treats each API value as plain text. The browser does not parse that value as HTML.
- Save public/index.html.
- Refresh the dashboard.
- Confirm the browser console remains free of JavaScript errors.
- Place the data-loading function below addField() but above the closing script tag by pasting this code:
async function loadOpportunities() {
try {
const response = await fetch('/api/opportunities');
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const opportunities = await response.json();
status.textContent = `${opportunities.length} public opportunities loaded`;
} catch (error) {
status.textContent = `Could not load opportunities: ${error.message}`;
}
}
loadOpportunities();
How does the browser load data?
The relative request reaches the API route on the same local server. The browser parses its response before updating the live status.
The error path replaces the loading label with a useful failure summary. A successful request reports the number of returned records.
- Save public/index.html.
- Refresh the dashboard.
- Confirm the status changes to 2 public opportunities loaded.
That request worked. Your page can now read the live local API instead of showing a permanent loading message.
- Place the card-building loop below the status update inside the try block by pasting this code:
for (const opportunity of opportunities) {
const card = document.createElement('article');
card.className = 'card';
const heading = document.createElement('h2');
heading.textContent = opportunity.title;
card.appendChild(heading);
addField(card, 'Organization', opportunity.organization);
addField(card, 'Location', opportunity.location);
addField(card, 'Deadline', opportunity.deadline);
addField(card, 'Summary', opportunity.summary);
grid.appendChild(card);
}
How are the cards assembled?
Each opportunity becomes a new article with its title as the heading. Four calls to addField() add the remaining public information.
The loop never places internalContact or reviewNotes on a card. This keeps the interface clean while leaving the API response unchanged.
- Save public/index.html.
- Refresh the dashboard.
- Confirm you see one card for Digital Skills Exchange plus one card for Regional Leadership Sprint.
- Place the card styles below the .grid style by pasting this code:
.card {
padding: 1.5rem;
border: 1px solid #d7e0ee;
border-radius: 1rem;
background: rgb(255 255 255 / 90%);
box-shadow: 0 18px 45px rgb(31 54 88 / 10%);
}
.card h2 {
margin-top: 0;
}
.card p {
margin: 0.55rem 0;
line-height: 1.5;
}
What creates the finished card design?
Spacing plus rounded borders separate each opportunity from the page. The translucent background plus shadow lift the cards above the gradient.
- Save public/index.html.
- Refresh the dashboard.
- Confirm both opportunities now appear as rounded white cards with soft shadows.
Cards not appearing?
Confirm the PowerShell server process is still running. Check that loadOpportunities() appears before the closing script tag.
Need help? Help me find why my opportunity cards do not render.
✔️ Awesome, I've got everything!
Your dashboard loads two styled fictional opportunities from the local API.
ⓧ 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>Youth Opportunities</title>
<style>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #eef3f9;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, #b9d7ff 0, transparent 32rem),
#eef3f9;
}
main {
width: min(960px, calc(100% - 2rem));
margin: 0 auto;
padding: 4rem 0;
}
.eyebrow {
color: #245bb5;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
max-width: 700px;
margin: 0.75rem 0;
font-size: clamp(2.2rem, 7vw, 4.5rem);
line-height: 0.98;
}
.intro {
max-width: 650px;
color: #4d5b73;
font-size: 1.1rem;
line-height: 1.7;
}
#status {
margin: 2rem 0 1rem;
color: #245bb5;
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.card {
padding: 1.5rem;
border: 1px solid #d7e0ee;
border-radius: 1rem;
background: rgb(255 255 255 / 90%);
box-shadow: 0 18px 45px rgb(31 54 88 / 10%);
}
.card h2 {
margin-top: 0;
}
.card p {
margin: 0.55rem 0;
line-height: 1.5;
}
</style>
</head>
<body>
<main>
<span class="eyebrow">Secure API portfolio demo</span>
<h1>Youth opportunities worth sharing</h1>
<p class="intro">
A fictional dashboard that demonstrates why privacy controls belong on
the server, not only in the interface.
</p>
<p id="status" aria-live="polite">Loading opportunities...</p>
<section
id="opportunities"
class="grid"
aria-label="Youth opportunities"
></section>
</main>
<script>
const status = document.getElementById('status');
const grid = document.getElementById('opportunities');
function addField(card, label, value) {
const line = document.createElement('p');
line.textContent = `${label}: ${value}`;
card.appendChild(line);
}
async function loadOpportunities() {
try {
const response = await fetch('/api/opportunities');
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const opportunities = await response.json();
status.textContent = `${opportunities.length} public opportunities loaded`;
for (const opportunity of opportunities) {
const card = document.createElement('article');
card.className = 'card';
const heading = document.createElement('h2');
heading.textContent = opportunity.title;
card.appendChild(heading);
addField(card, 'Organization', opportunity.organization);
addField(card, 'Location', opportunity.location);
addField(card, 'Deadline', opportunity.deadline);
addField(card, 'Summary', opportunity.summary);
grid.appendChild(card);
}
} catch (error) {
status.textContent = `Could not load opportunities: ${error.message}`;
}
}
loadOpportunities();
</script>
</body>
</html>
How to use this reference
Compare this full reference with your saved public/index.html file. Check the placement of the styles plus markup plus script.
Before you inspect the endpoint, do you think the raw response contains only the six fields shown on each card?
- Open a new browser tab.
- Enter http://127.0.0.1:3000/api/opportunities in the address bar.
- Compare every returned property with the information displayed on the dashboard cards.
You will see internalContact plus reviewNotes in both raw objects. The attractive dashboard concealed those properties without preventing the server from disclosing them.
The hidden leak is visible
This is the intended security failure. Anyone who can request the endpoint can read every serialized property even when the interface ignores some of them.
The issue demonstrates API3:2023 Broken Object Property Level Authorization. The public response contains sensitive object properties that its consumer does not need.
You have reproduced the exact gap this project is designed to fix. Next, you will replace raw serialization with a server-side response allowlist while keeping both cards working.
Apply a Server-Side Allowlist
Your dashboard now renders two fictional opportunities. However, the raw API response exposes internalContact plus reviewNotes.
The leak exists because the route serializes each complete internal object. A server-side allowlist applies data minimization at the response boundary for API3:2023 Broken Object Property Level Authorization.
In this step, get ready to:
- Create public response objects containing only the six approved properties.
- Connect the API route to the allowlisted collection.
- Prove that private fields no longer cross the API boundary.
Build the public response boundary
An allowlist names every property permitted to leave the server. The serializer creates a fresh response object without changing the original internal record.
- Select opportunities.js in the Visual Studio Code Explorer sidebar.
- Locate the closing ]; after the second fictional opportunity.
- Place your cursor on the blank line below the array.
- Add the public serializer plus collection mapper by pasting this code:
function toPublicOpportunity(opportunity) {
return {
id: opportunity.id,
title: opportunity.title,
organization: opportunity.organization,
location: opportunity.location,
deadline: opportunity.deadline,
summary: opportunity.summary,
};
}
function getPublicOpportunities() {
return opportunities.map(toPublicOpportunity);
}
How does the allowlist protect data?
- The toPublicOpportunity() function receives one complete internal record.
- The returned object copies only the six properties approved for public use.
- The getPublicOpportunities() function applies the serializer to every record with map().
- The raw array keeps both private properties inside server memory.
- Save opportunities.js.
- Confirm that toPublicOpportunity() appears before getPublicOpportunities() below the array.
Seeing broken function syntax?
Check that both functions sit after the array's closing ];. A function placed inside either record breaks the array structure.
Check that the returned public object closes with }; before the serializer function closes.
Still stuck? Help me check the serializer placement and braces.
Other files can use these functions only after CommonJS exports them. The export block keeps the internal array available while exposing the two public response helpers.
- Locate the existing module.exports block at the bottom of opportunities.js.
- Select the complete export block.
- Replace the selected block with this updated export object:
module.exports = {
opportunities,
toPublicOpportunity,
getPublicOpportunities,
};
What does the export expose?
- The opportunities export preserves access to the internal records inside the project.
- The toPublicOpportunity export makes the one-record serializer reusable.
- The getPublicOpportunities export gives the API route a public collection to serialize.
- Save opportunities.js.
- Confirm that the export block lists all three identifiers with matching capitalization.
Missing an exported function?
Check that toPublicOpportunity plus getPublicOpportunities appear inside the same module.exports object as opportunities.
Need help? Help me compare the function names with my export block.
✔️ Awesome, I've got everything!
Your internal records now have a separate public response boundary. Double-check that opportunities.js is saved.
ⓧ I'd like to double check the full code
- Compare your complete opportunities.js file with this reference:
const opportunities = [
{
id: 'opp-001',
title: 'Digital Skills Exchange',
organization: 'Community Youth Lab',
location: 'Tivat, Montenegro',
deadline: '2026-12-15',
summary: 'A fictional peer-led workshop for young people exploring web development.',
internalContact: 'Internal coordinator channel 17',
reviewNotes: 'Demo-only note. Do not expose this field.',
},
{
id: 'opp-002',
title: 'Regional Leadership Sprint',
organization: 'Regional Youth Network',
location: 'Podgorica, Montenegro',
deadline: '2027-01-20',
summary: 'A fictional weekend program focused on teamwork and community projects.',
internalContact: 'Internal coordinator channel 23',
reviewNotes: 'Demo-only note. Verify venue capacity before publishing.',
},
];
function toPublicOpportunity(opportunity) {
return {
id: opportunity.id,
title: opportunity.title,
organization: opportunity.organization,
location: opportunity.location,
deadline: opportunity.deadline,
summary: opportunity.summary,
};
}
function getPublicOpportunities() {
return opportunities.map(toPublicOpportunity);
}
module.exports = {
opportunities,
toPublicOpportunity,
getPublicOpportunities,
};
What should match?
The raw array still holds both private fields. The two functions create new public objects containing only approved properties.
Before you refresh the raw endpoint, do you think defining the allowlist has changed the running API response?
- Return to the browser tab showing http://127.0.0.1:3000/api/opportunities.
- Refresh the raw endpoint.
The response still exposes internalContact plus reviewNotes. This proves that an allowlist protects nothing until the route uses it.
Connect the route to public data
The API route currently imports the raw array. It also passes that array directly to JSON.stringify().
- Switch back to server.js in the Explorer sidebar.
- Find the import containing opportunities from ./opportunities.
- Replace the imported identifier opportunities with getPublicOpportunities.
- Find JSON.stringify(opportunities) inside the GET /api/opportunities route.
- Replace that expression with JSON.stringify(getPublicOpportunities()).
- Save server.js.
Where is the security boundary?
The route now requests newly created public objects before converting the collection to JSON. The response path no longer receives the raw internal array.
✔️ Awesome, I've got everything!
The API route now imports and calls getPublicOpportunities(). Double-check that server.js is saved.
ⓧ I'd like to double check the full code
- Compare your complete server.js file with this reference:
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { getPublicOpportunities } = require('./opportunities');
const HOST = '127.0.0.1';
const PORT = 3000;
function send(response, statusCode, contentType, body) {
response.writeHead(statusCode, { 'Content-Type': contentType });
response.end(body);
}
const server = http.createServer((request, response) => {
if (request.method === 'GET' && request.url === '/api/opportunities') {
send(
response,
200,
'application/json; charset=utf-8',
JSON.stringify(getPublicOpportunities()),
);
return;
}
if (request.method === 'GET' && request.url === '/') {
const pagePath = path.join(__dirname, 'public', 'index.html');
fs.readFile(pagePath, 'utf8', (error, html) => {
if (error) {
send(
response,
500,
'application/json; charset=utf-8',
JSON.stringify({ error: 'Could not load the dashboard.' }),
);
return;
}
send(response, 200, 'text/html; charset=utf-8', html);
});
return;
}
send(
response,
404,
'application/json; charset=utf-8',
JSON.stringify({ error: 'Not found.' }),
);
});
server.listen(PORT, HOST, () => {
console.log(`Youth Opportunities API running at http://${HOST}:${PORT}`);
});
What changed in the route?
The import supplies the public collection function. The API route calls that function each time it prepares a successful response.
Restart and verify the repair
The running process still holds the vulnerable route in memory. Restarting the server loads the saved allowlist changes.
- Return to the PowerShell terminal from earlier.
- Stop the old server by pressing Ctrl+C.
Before you restart the server, which properties do you expect the endpoint to return?
- Start the repaired server by running this command:
node server.js
Why does the restart matter?
The new Node.js process loads the saved module exports. It also loads the route that calls getPublicOpportunities().
You should see confirmation that the Youth Opportunities API is running at http://127.0.0.1:3000.
Server not restarting?
Check that the earlier process stopped before you ran the command. A process still using port 3000 blocks the replacement server.
Check that both uses of getPublicOpportunities have identical capitalization.
Still blocked? Help me debug the restarted allowlisted API.
Before you refresh the raw endpoint, predict which six fields should remain in each object.
- Return to the browser tab showing http://127.0.0.1:3000/api/opportunities.
- Refresh the raw endpoint.
Each object now contains only id, title, organization, location, deadline, plus summary.
- Press Ctrl+F in the raw response tab.
- Search for internalContact.
- Search for reviewNotes.
Neither search should find a match. The private values remain inside the raw records in server memory.
- Return to the dashboard tab at http://127.0.0.1:3000.
- Refresh the dashboard.
You should still see two opportunity cards. The status should report that two public opportunities loaded.
That closes the leak. Your server now enforces the public response contract before any opportunity data reaches the browser.
Your API now applies data minimization at the server boundary. Next, you will protect this behavior with automated regression tests.
Add Security Regression Tests
Your server-side allowlist now keeps internalContact and reviewNotes inside the trusted process. The secured dashboard in Visual Studio Code still receives the six approved public fields.
A manual check can be forgotten during a future refactor. In this step, you'll use the Node.js test runner to create regression tests that fail if the data leak returns.
In this step, get ready to:
- Add a test that locks the exact approved response shape.
- Add a test that checks every public object for forbidden internal fields.
- Run the complete security test suite.
Test the approved response shape
A response allowlist protects users while its approved shape stays stable. The first test freezes the expected first object so an accidental property change becomes visible.
- In the Explorer sidebar, select test/security.test.js.
- Add the test imports and the approved-shape test by pasting this code:
const test = require('node:test');
const assert = require('node:assert/strict');
const { getPublicOpportunities } = require('../opportunities');
test('public response keeps the approved opportunity fields', () => {
const [firstOpportunity] = getPublicOpportunities();
assert.deepStrictEqual(firstOpportunity, {
id: 'opp-001',
title: 'Digital Skills Exchange',
organization: 'Community Youth Lab',
location: 'Tivat, Montenegro',
deadline: '2026-12-15',
summary: 'A fictional peer-led workshop for young people exploring web development.',
});
});
What does this test protect?
- The node:test module registers a named test with the built-in test runner.
- The node:assert/strict module provides the strict comparison used by the test.
- The getPublicOpportunities() import exercises the same response serializer used by the API route.
- The deepStrictEqual() assertion requires firstOpportunity to match the approved object exactly.
- Save test/security.test.js.
- In the integrated terminal panel, create a second PowerShell terminal so the running server stays available.
Before you run the test suite, do you expect the exact shape check to pass against your current allowlist?
- Run the first regression test by running this command:
node --test
What should you see?
The node --test command starts the built-in test runner. It discovers test/security.test.js automatically.
You should see public response keeps the approved opportunity fields pass with no failures. Your approved response contract now has its first automated guard.
🙋♀️ Seeing a test failure?
- Confirm that security.test.js is inside the test folder.
- Check that require('../opportunities') contains two dots before the slash.
- Compare every value in the expected object with the first fictional record in opportunities.js.
Still stuck? Help me diagnose why my approved response shape test is failing.
Test the forbidden fields
The exact first-object check protects one representative response. The second test scans every public object so neither private property can return unnoticed.
- In test/security.test.js, place the forbidden-fields test below the first test by pasting this code:
test('public response excludes internal fields', () => {
for (const opportunity of getPublicOpportunities()) {
assert.equal('internalContact' in opportunity, false);
assert.equal('reviewNotes' in opportunity, false);
}
});
What does this test protect?
- The for...of loop checks every object returned by getPublicOpportunities().
- The in operator detects whether each internal property exists on an object.
- Each assert.equal() call requires the property check to return false.
- A future serializer change now fails the suite if internalContact or reviewNotes crosses the public boundary.
- Save test/security.test.js.
Before you rerun the suite, do you expect any private property to survive getPublicOpportunities()?
- Run both regression tests by running this command:
node --test
What should you see?
This rerun executes both named security tests. You should see public response keeps the approved opportunity fields and public response excludes internal fields pass.
The test runner should report two passing tests with no failures. You now have executable evidence that approved fields remain while internal fields stay private.
🙋♀️ Is the second test failing?
- Compare the spellings of internalContact and reviewNotes with the raw records in opportunities.js.
- Confirm that both assertions remain inside the for...of loop.
- Check that the loop reads from getPublicOpportunities().
Need another pair of eyes? Help me find why my forbidden-fields regression test is failing.
✔️ Awesome, I've got everything!
Both security safeguards are in place. Your saved test file is ready for the publishing step.
ⓧ I'd like to double check the full code
const test = require('node:test');
const assert = require('node:assert/strict');
const { getPublicOpportunities } = require('../opportunities');
test('public response keeps the approved opportunity fields', () => {
const [firstOpportunity] = getPublicOpportunities();
assert.deepStrictEqual(firstOpportunity, {
id: 'opp-001',
title: 'Digital Skills Exchange',
organization: 'Community Youth Lab',
location: 'Tivat, Montenegro',
deadline: '2026-12-15',
summary: 'A fictional peer-led workshop for young people exploring web development.',
});
});
test('public response excludes internal fields', () => {
for (const opportunity of getPublicOpportunities()) {
assert.equal('internalContact' in opportunity, false);
assert.equal('reviewNotes' in opportunity, false);
}
});
Why this is the checkpoint
This reference shows the complete test/security.test.js checkpoint. Matching it preserves the approved response shape while rejecting both internal properties.
Your security fix now has executable evidence behind it. Next, you'll publish the API and its security story as an inspectable GitHub project.
Publish Your Security Story
Your secured Node.js API now has a working response allowlist. Two regression tests protect its public contract.
Employers cannot evaluate a result that only exists on your computer.
Publishing the project to GitHub makes your security story inspectable. The README and commit history connect the flaw to its evidence-backed fix.
In this step, get ready to:
- Document the vulnerable behavior and server-side fix in README.md.
- Create a Git commit containing the tested project.
- Publish the project in a public GitHub repository.
Write the security README
A README gives visitors the context they need before reading your code. Markdown keeps that explanation structured on the repository page.
- Switch back to the empty README.md file in Visual Studio Code.
- Replace the empty contents with this security story:
# Secure Youth Opportunities API
A small Node.js portfolio project demonstrating OWASP API3:2023 Broken Object Property Level Authorization with fictional youth opportunity data.
## Run locally
```powershell
node server.js
```
Open `http://127.0.0.1:3000` in a browser.
## Run the security tests
```powershell
node --test
```
## Security finding
The vulnerable version returned complete internal records from `/api/opportunities`. The dashboard displayed only public properties, but the raw response still included `internalContact` and `reviewNotes`.
## Fix
`toPublicOpportunity()` creates a new response object from an explicit allowlist. `getPublicOpportunities()` applies that serializer before data reaches the route. The regression tests protect both the approved response shape and the exclusion of internal fields.
## Safe use
All records are fictional. This project is a local educational demonstration and contains no real participant data, credentials, or API keys.
What does this README prove?
- The introduction names OWASP API3:2023 Broken Object Property Level Authorization as the security category demonstrated by the project.
- The run instructions make the dashboard and regression tests reproducible.
- The security finding records how complete internal objects reached the public response.
- The fix explains how the server-side allowlist blocks those private properties.
- Save README.md.
- Open the Markdown preview in Visual Studio Code.
- Confirm the preview shows Secure Youth Opportunities API at the top.
- Confirm both PowerShell commands appear in separate code blocks.
Your README now presents the vulnerability as a complete before-and-after security demonstration.
README preview missing sections?
Check that every heading starts with the same number of # characters shown above. Confirm that each PowerShell block has an opening fence and a closing fence.
Still seeing broken formatting? Help me troubleshoot my README formatting.
✔️ Awesome, I've got everything!
Your saved README contains the complete security finding and fix.
ⓧ I'd like to double check the full code
# Secure Youth Opportunities API
A small Node.js portfolio project demonstrating OWASP API3:2023 Broken Object Property Level Authorization with fictional youth opportunity data.
## Run locally
```powershell
node server.js
```
Open `http://127.0.0.1:3000` in a browser.
## Run the security tests
```powershell
node --test
```
## Security finding
The vulnerable version returned complete internal records from `/api/opportunities`. The dashboard displayed only public properties, but the raw response still included `internalContact` and `reviewNotes`.
## Fix
`toPublicOpportunity()` creates a new response object from an explicit allowlist. `getPublicOpportunities()` applies that serializer before data reaches the route. The regression tests protect both the approved response shape and the exclusion of internal fields.
## Safe use
All records are fictional. This project is a local educational demonstration and contains no real participant data, credentials, or API keys.
Review and commit the project
Publishing code can feel risky because committed content becomes part of the project history. This review keeps passwords, API keys, credentials, and real participant data out of that history.
- Return to the PowerShell terminal you used for Git.
- Stage the completed project by running this command:
git add .
What does this command do?
The command stages the project files for the next commit. Staging gives you one final review point before the snapshot enters Git history.
- Use Visual Studio Code's source control view to inspect the staged files.
- Confirm the staged project contains server.js, opportunities.js, README.md, public/index.html, and test/security.test.js.
- Check that the records contain fictional opportunity information only.
- Check that no password, API key, credential, or real participant data appears in the staged content.
- Create the project commit by running this command:
git commit -m "Build and secure youth opportunities API"
What does this commit capture?
The commit records the dashboard, vulnerable-data demonstration, server-side allowlist, regression tests, and security documentation as one project snapshot.
Git prints a commit summary containing the message you supplied. That commit now preserves the complete security story on main.
Commit not created?
If Git asks for your identity, follow its instructions to configure the name and email attached to your commits. Run the commit command again after completing that setup.
Need help with the result in your terminal? Help me troubleshoot my Git commit.
Create and push the public repository
The local commit needs a remote destination before anyone else can inspect it. An empty public repository avoids conflicts with the files already committed on main.
- Sign in to GitHub in your browser.
- Select New repository from the upper-right menu.
- Choose your personal account from the Owner menu.
- Enter secure-youth-opportunities-api in the Repository name field.
- Select Public as the repository visibility.
- Leave every repository initialization option unselected.
- Click Create repository.
You should see the empty repository's Quick Setup page. This confirms GitHub is ready to receive your existing commit.
- Copy the repository URL from the Quick Setup page.
- Record the copied URL here: https://github.com/your-username/secure-youth-opportunities-api.git.
- Connect the local repository to that GitHub URL by running this command:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/secure-youth-opportunities-api.git"]]
What does this remote do?
The command stores the GitHub repository under the remote name origin. Your local commit now has a named destination for the push.
- Verify the stored remote by running this command:
git remote -v
What should you see?
You should see the recorded repository URL listed twice under origin. Both lines should point to your public secure-youth-opportunities-api repository.
The first push may trigger a browser sign-in. This request authorizes Git to publish to your own GitHub repository.
Before you push, what do you expect the empty repository page to show after Git receives your commit?
- Push main to GitHub by running this command:
git push -u origin main
What does this push do?
The command uploads the local main branch to origin. It also links the local branch to its remote counterpart for future pushes.
You should see Git complete the upload without a failure. Your commit is now available from the public repository.
- Return to the GitHub repository page in your browser.
- Refresh the repository page.
- Confirm the repository contains server.js, opportunities.js, README.md, public/index.html, and test/security.test.js.
- Confirm the security README is rendered below the file list.
- Open the repository's commit history.
- Confirm the history contains Build and secure youth opportunities API on main.
You have turned a local security exercise into a public portfolio artifact. Visitors can now inspect the vulnerability, server-side repair, regression evidence, and fictional data policy.
Push not reaching GitHub?
If authentication opens in your browser, finish signing in with the GitHub account that owns the repository. Run the push command again after the browser confirms access.
If the repository cannot be found, compare the owner and repository name from the remote output with the GitHub page. Help me troubleshoot my GitHub push.
Secret mission
Add Automated Code Scanning
Extend your public security portfolio with GitHub CodeQL default setup for JavaScript. Follow the automated workflow until the analysis completes. Review the code-scanning result and investigate any alerts it reports.
Clean Up Your Resources
Clean Up Your Resources
This project has no ongoing cost because the server runs on your computer while the public repository uses GitHub Free. Choose whether to keep the portfolio available, stop the local server for later, or remove the project.
Resources you used:
- Local secure-youth-opportunities-api folder containing the completed Git repository.
- Local Node.js process serving the dashboard at http://127.0.0.1:3000.
- Public GitHub repository named secure-youth-opportunities-api with the rendered README and pushed commit history.
- Enabled GitHub CodeQL default setup with a reviewed JavaScript analysis result.
Keep everything running
No action needed. Choose this if you want the dashboard available locally while the public repository presents your security work.
- Leave the public GitHub repository available as your portfolio artifact.
- Keep the local secure-youth-opportunities-api folder for future changes.
- Keep the PowerShell terminal running while you want the dashboard available at http://127.0.0.1:3000.
Pause - I'll come back to this later
Stop the running process to free the terminal. Your local folder and public repository stay intact for another session.
- Switch back to the Visual Studio Code PowerShell terminal from earlier.
- Press Ctrl+C to stop the Node.js server.
- Confirm the PowerShell prompt returns.
That pause is complete. Your project remains ready for another session.
Delete - I don't want to use this again
Permanent deletion can feel drastic. A separate backup is your only fallback after both project copies are gone.
- Switch back to the Visual Studio Code PowerShell terminal from earlier.
- Press Ctrl+C to stop the Node.js server.
- Use the Explorer sidebar to reveal the secure-youth-opportunities-api workspace folder in Windows File Explorer.
- Close Visual Studio Code.
- Select the secure-youth-opportunities-api folder in Windows File Explorer.
- Use Windows File Explorer's delete action to remove the folder.
- Confirm the secure-youth-opportunities-api folder no longer appears in Windows File Explorer.
Your local copy is now removed. The public repository is the final project resource.
- Open the repository settings for the public secure-youth-opportunities-api repository to locate the repository deletion control.
- Use the repository deletion control to permanently delete secure-youth-opportunities-api.
- Complete the confirmation that GitHub presents.
- Return to the old repository page to confirm it is unavailable.
That clears both project copies. Your local folder and public portfolio are now removed.
Nice Work!
Nice Work!
Outstanding work! Your public GitHub repository now tells a complete Node.js security story from exposed fields to a tested server-side fix.
You've learned how to:
- Build a Node.js HTTP API that serves a browser dashboard of fictional youth opportunities. Render API values through safe DOM text handling.
- Reproduce OWASP API3:2023 Broken Object Property Level Authorization through leaked internal fields. Repair the endpoint with data minimization plus a server-side response allowlist.
- Protect the public response contract with two regression tests. Publish the evidence through a security-focused README.md plus a clear Git history.
- Secret Mission: Enabled GitHub CodeQL default setup for JavaScript. Reviewed the completed code-scanning result plus any alerts.
Ready to quiz yourself?
Share Your Feedback
Your feedback helps improve this project for the next learner.