WEBSOCKET ORIGIN, DNS REBINDING, AUDIT LOG TAMPER

Three bugs from confusion about who you trust. WebSocket connections accepted from any origin. Host headers trusted for routing decisions when an attacker can rebind DNS. Audit log entries with fields the client supplied.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.

Bugs that ask the wrong question

The three bugs in this article share a structure: the server makes a trust decision based on data that’s less reliable than it appears.

WebSocket without origin checks

const wss = new WebSocketServer({ server })
wss.on('connection', (ws, req) => {
  // No origin check
  ws.on('message', (msg) => handleMessage(JSON.parse(msg), req.user))
})

Looks fine. The session middleware authenticated the user before the WebSocket upgrade. The WebSocket is bound to a logged-in session. Every message from this client is from that user.

The bug: the WebSocket upgrade is the only authenticated step. The browser’s Origin header is supposed to tell you which origin opened the connection, and you’re supposed to refuse origins you don’t trust. Without that check, attacker-site.example can open a WebSocket to your server, with the user’s cookies, from a script the attacker controls. Now the attacker’s script sends arbitrary messages on the user’s session.

This is essentially the WebSocket version of CORS-with-credentials. Same root cause: the server didn’t enforce the cross-origin policy.

Fix:

const wss = new WebSocketServer({
  server,
  verifyClient: ({ origin }) => allowedOrigins.includes(origin),
})

Live: https://gapbench.vibe-eval.com/site/websocket-no-origin/.

DNS rebinding

This is a stranger attack and worth understanding even if you’re not currently vulnerable to it.

Setup. Your app runs at app.example.com. Inside the same network, on the same machine, you also have an admin service at localhost:8080 that has no auth (because it’s localhost and you trusted that boundary). Your app reads requests, makes routing decisions based on Host. You enforce that Host must be app.example.com.

The attack. Attacker registers attacker.example. They set DNS for attacker.example to your IP, with a TTL of zero. They get the user to load https://attacker.example in a browser. The browser resolves the domain, connects, loads the attacker’s JavaScript. Now the attacker’s script does a fetch to attacker.example/admin. The browser, holding the connection open, doesn’t re-resolve. The request goes to your server with Host: attacker.example.

Then the attacker switches the DNS to 127.0.0.1. The next fetch from the same origin in the browser, after a brief delay, resolves to localhost. Now the attacker’s script can fetch from localhost:8080/admin/whatever — but the browser still thinks it’s all from attacker.example, so same-origin policy lets the script read responses.

The attacker has used the user’s browser as a proxy to talk to localhost services on the user’s machine.

Mitigation: your localhost services should never trust localhost-ness alone. They should require auth. Also: enforce Host-header allow-lists at the application level — refuse requests where Host isn’t a value you recognize, even if the network connection accepted them.

Live: https://gapbench.vibe-eval.com/site/dns-rebinding/.

Audit log tamper

async function logAuditEvent(action, req) {
  await db.audit.create({
    data: {
      action,
      userId: req.session.userId,
      userAgent: req.headers['user-agent'],
      ip: req.headers['x-forwarded-for'] || req.ip,
      details: req.body.auditDetails,  // <-- attacker controls this
    }
  })
}

Two issues. First, userAgent and x-forwarded-for are client-supplied; the attacker can put whatever they want there. Second, auditDetails from req.body is the attacker’s free-form text in the log entry.

Why does this matter? Three failure modes:

  1. Log forging. An attacker takes an action, then provides auditDetails that look like a normal log entry from a different user. Anyone reading the log gets the wrong story.
  2. Log injection. If the log is rendered to HTML somewhere (admin dashboard, log viewer), the attacker can inject HTML into it. XSS on the log viewer.
  3. Detection evasion. The attacker takes a sensitive action and provides auditDetails: "" so the entry looks innocuous, knowing the alerting rules look for specific patterns in auditDetails.

Fix: audit log fields should be generated server-side from authoritative sources. The userId comes from the session. The action comes from a hardcoded string in the code that triggered the log. The IP comes from a trusted source (your reverse proxy, with proper trusted-proxies config). Client-controlled fields, if logged at all, get logged as client_supplied_X so anyone reading knows what they’re looking at.

Live: https://gapbench.vibe-eval.com/site/audit-log-tamper/.

A specific incident — WebSocket without origin check

Anonymized. A real-time collaborative product (think shared canvas / shared document) used WebSockets for state sync between clients. Authentication was via cookie on the upgrade request — standard pattern, browser includes the auth cookie when opening the WebSocket. After upgrade, every message on the socket was treated as coming from the authenticated user.

The team had no origin check on the upgrade. We discovered this by opening a WebSocket from attacker.example to wss://app.example.com/ws from a test page; the upgrade succeeded with the user’s cookies; the test page received the user’s real-time stream of state updates. Attacker can now read every action the user takes.

Worse: the test page could send messages too. The product’s state updates were structured as {type: 'set', path: 'document.title', value: '...'} — no per-message authorization. The attacker’s page could send arbitrary state mutations that the product treated as legitimate user actions.

Fix: add verifyClient to the WebSocket server config that validates event.origin against the allowed origin list. One-line config; same shape as CORS for fetches.

The lesson: WebSockets have the same cross-origin trust issues as fetch, but the framework defaults are less helpful. CORS for fetch will, by default, refuse cross-origin reads. WebSocket has no equivalent default; the server has to enforce origin manually. AI codegen, copying tutorials, often skips this step.

DNS rebinding in the modern era

DNS rebinding got attention again recently because of the proliferation of localhost services — Electron apps, AI tools running local servers, IoT devices. The attack:

  1. Attacker registers attacker.example with TTL 0.
  2. Victim loads https://attacker.example. DNS resolves to attacker’s IP. JavaScript loads.
  3. JavaScript makes a fetch back to attacker.example/path. Browser keeps the connection or re-resolves.
  4. Attacker switches DNS to 127.0.0.1. Next fetch goes to localhost on the victim’s machine.
  5. Same-origin policy treats it as attacker.example, so the JS reads the response.

If the victim has a localhost service (a debugger, an Electron IPC port, a local AI agent), the attacker’s JS can talk to it from within the victim’s same-origin policy.

The mitigation isn’t at the DNS layer — DNS is functioning as designed. It’s at the application layer:

  1. Localhost services should require auth even on localhost. Token from an env var, header check, etc. Don’t trust localhost as an auth boundary.
  2. Localhost services should validate Host header. Refuse requests where Host isn’t 127.0.0.1, localhost, or whatever you expect.
  3. Cross-origin restrictions on fetch. A localhost service that returns CORS headers refusing cross-origin reads breaks the attack at the JS layer.

For browser-based apps, you usually don’t need to worry about DNS rebinding directly. For Electron / Tauri / native-with-local-server architectures, it’s a real surface.

Audit log integrity

Audit logs are special. They’re the record of “what happened” used for forensics, compliance, and detection. If they can be tampered with, none of those work.

The properties to require:

  1. Append-only. No update or delete after write. Use a database table with no UPDATE/DELETE permissions for the application user.
  2. Server-generated fields only. User identity from session, action from hardcoded string, IP from trusted proxy header, timestamp from server clock. No client-supplied fields in fields the audit reader will rely on.
  3. Tamper-evident chaining. Each entry includes a hash of the previous entry. Modifying one entry breaks the chain for all subsequent entries. Cheap to implement, makes silent tampering detectable.
  4. Replication off-host. Audit log shipped to a separate logging system as it’s written. If the host is compromised, the log on the other system survives.

We see audit logs implemented as “just another table” with the application’s full read/write/delete access. That’s not an audit log; it’s a log file. AI codegen produces this shape because it’s the simplest pattern and the model doesn’t know the audit log is supposed to be more constrained.

Wrong fix vs right fix — WebSocket

// WRONG: no origin check
const wss = new WebSocketServer({ server, path: '/ws' })
wss.on('connection', (ws, req) => {
  // any origin can connect with the user's cookies
})
// RIGHT: origin check via verifyClient
const wss = new WebSocketServer({
  server,
  path: '/ws',
  verifyClient: (info, cb) => {
    const allowedOrigins = ['https://app.example.com']
    if (!allowedOrigins.includes(info.origin)) {
      return cb(false, 403, 'Origin not allowed')
    }
    cb(true)
  },
})

Cross-stack notes

  • ws (Node): verifyClient option is the standard.
  • socket.io: cors option, similar shape.
  • Python (FastAPI WebSockets): Manual origin check in the route handler.
  • Go (gorilla/websocket): Upgrader.CheckOrigin function.
  • Phoenix Channels (Elixir): :check_origin config setting.

How we detect

WebSocket origin: we open a WebSocket to your endpoint with Origin: https://attacker.example. If the upgrade succeeds, finding.

DNS rebinding: we don’t fully simulate the attack (it requires DNS infrastructure), but we probe whether your app accepts requests with arbitrary Host headers, and whether internal services on common ports are reachable through your app’s request handling.

Audit log tamper: we trigger audit-logged actions with crafted client-supplied fields, then read the resulting log entries (when we have side-channel access for testing, or via a follow-up admin-side query).

CWE / OWASP

  • CWE-346 — Origin Validation Error
  • CWE-918 — Server-Side Request Forgery (DNS rebinding adjacency)
  • CWE-117 — Improper Output Neutralization for Logs
  • OWASP Top 10 — A05:2021 Security Misconfiguration, A09:2021 Security Logging and Monitoring Failures

Reproduce it yourself

COMMON QUESTIONS

01
What's the WebSocket origin issue?
WebSocket has the same cross-origin model as fetch — sort of. The browser includes the Origin header on the upgrade request, and the server is supposed to check it. If the server doesn't, any origin can open a WebSocket to your server with the user's cookies attached. This is essentially CORS for WebSocket; the spec assumes the server enforces it, the AI generator doesn't always remember to.
Q&A
02
What is DNS rebinding?
Your app trusts the Host header (or some derivative) to make routing decisions — for example, only accepting requests where Host matches an allow-list. An attacker controls a domain, sets short TTLs, and points it at your IP. The browser caches the resolution. After connecting, the attacker rebinds the domain to a different IP — internal services, or back to the attacker's server — and uses the still-open browser connection for cross-origin actions. The browser thinks it's still the same origin.
Q&A
03
What is audit log tamper?
Audit logs are only useful if they reflect what actually happened. If your audit log entry includes fields the client supplied — user agent, custom 'who did this' fields, timestamps — an attacker can falsify them. Worse, if the audit log entry is written by code the user can influence (a function that takes user-controlled fields and writes them to the log), the log can be rewritten or replaced.
Q&A
04
Why do AI generators reproduce these?
WebSocket origin: the AI's WebSocket setup follows tutorial code that often skips the origin check. DNS rebinding: trusting Host is in framework defaults sometimes; the mitigation is a trusted-hosts config. Audit log tamper: the AI generates 'log this action' code that takes whatever it has at hand, including client-supplied fields.
Q&A
05
Where can I see this on a real URL?
https://gapbench.vibe-eval.com/site/websocket-no-origin/, https://gapbench.vibe-eval.com/site/dns-rebinding/, https://gapbench.vibe-eval.com/site/audit-log-tamper/.
Q&A
06
What CWE does this map to?
CWE-346 (Origin Validation Error), CWE-918 (SSRF, related to DNS rebinding), CWE-117 (Improper Output Neutralization for Logs), CWE-1284 (Improper Validation of Specified Quantity in Input). OWASP A05:2021 (Security Misconfiguration), A09:2021 (Security Logging and Monitoring Failures).
Q&A

TEST YOUR TRUST BOUNDARIES

We probe WebSocket origin handling, DNS-rebinding-vulnerable host trust, and audit-log tamperability.

RUN THE SCAN