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:
- Log forging. An attacker takes an action, then provides
auditDetailsthat look like a normal log entry from a different user. Anyone reading the log gets the wrong story. - 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.
- Detection evasion. The attacker takes a sensitive action and provides
auditDetails: ""so the entry looks innocuous, knowing the alerting rules look for specific patterns inauditDetails.
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:
- Attacker registers
attacker.examplewith TTL 0. - Victim loads
https://attacker.example. DNS resolves to attacker’s IP. JavaScript loads. - JavaScript makes a fetch back to
attacker.example/path. Browser keeps the connection or re-resolves. - Attacker switches DNS to
127.0.0.1. Next fetch goes to localhost on the victim’s machine. - 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:
- Localhost services should require auth even on localhost. Token from an env var, header check, etc. Don’t trust localhost as an auth boundary.
- Localhost services should validate Host header. Refuse requests where Host isn’t
127.0.0.1,localhost, or whatever you expect. - 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:
- Append-only. No update or delete after write. Use a database table with no UPDATE/DELETE permissions for the application user.
- 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.
- 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.
- 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):verifyClientoption is the standard.- socket.io:
corsoption, similar shape. - Python (FastAPI WebSockets): Manual origin check in the route handler.
- Go (gorilla/websocket):
Upgrader.CheckOriginfunction. - Phoenix Channels (Elixir):
:check_originconfig 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
- WebSocket no origin: https://gapbench.vibe-eval.com/site/websocket-no-origin/
- DNS rebinding: https://gapbench.vibe-eval.com/site/dns-rebinding/
- Audit log tamper: https://gapbench.vibe-eval.com/site/audit-log-tamper/
Related reading
- Pattern: CORS = * with credentials = true
- Pattern: Request smuggling, CRLF splitting, cache poisoning
- Pattern: Mass assignment
COMMON QUESTIONS
TEST YOUR TRUST BOUNDARIES
We probe WebSocket origin handling, DNS-rebinding-vulnerable host trust, and audit-log tamperability.