Description
A dot (.) is a regular-expression metacharacter that matches a character rather than a literal hostname separator. Without escaping it as \., a hostname check may accept more strings than intended. For example, /^example.com$/ also matches exampleXcom or example-com. Depending on how the accepted value is used, this may bypass redirect or SSRF restrictions and send a user or server request to an unintended host.
Potential impact
- An open redirect may send users to an external site.
- Bypassing an SSRF filter may allow requests to internal or sensitive endpoints.
- Integrations intended for specific domains may reach other hosts.
- Similar-looking hostnames may facilitate phishing or damage trust in the service.
Remediation
- Escape domain-separator dots as
\., for example^((www|beta)\.)?example\.com$. - Anchor a hostname expression with
^and$to match the complete hostname. - Prefer exact allow-list comparisons, such as
host === 'example.com'or membership in a set of approved hostnames. - Parse URLs with a standard API and check
hostname, which excludes the port. - If subdomains are allowed, enforce the boundary:
host === 'example.com'orhost.endsWith('.example.com'). - Avoid broad matches such as
.*or.?and overlapping or nested repetitions. Enforce the intended label lengths and character rules.
Examples
URL redirects
Before
javascript
const express = require("express");
const app = express();
// /jump?next=https://example.com
app.get("/jump", (req, res) => {
const next = req.query.next;
if (!next) return res.status(400).send("missing");
let host;
try {
host = new URL(next).hostname; // Hostname of the user-supplied URL
} catch {
return res.status(400).send("bad url");
}
// Unescaped '.' matches more than intended
// For example, staticXexampleYcom and static-exampleZcom may pass
const allowed = /^((static|media).)?example.com$/i.test(host);
if (allowed) return res.redirect(next);
return res.status(400).send("blocked");
});
After
javascript
const express = require("express");
const app = express();
// Strict allow-list validation
const ALLOW_HOSTS = new Set([
"example.com",
"static.example.com",
"media.example.com",
]);
app.get("/jump", (req, res) => {
const next = req.query.next;
if (typeof next !== "string") return res.status(400).send("missing");
let parsed;
try {
parsed = new URL(next);
} catch {
return res.status(400).send("bad url");
}
const host = parsed.hostname.toLowerCase();
const safeTransport =
parsed.protocol === "https:" &&
parsed.port === "" &&
parsed.username === "" &&
parsed.password === "";
// Allow only exact hostnames and the default HTTPS port
if (!safeTransport || !ALLOW_HOSTS.has(host)) {
return res.status(400).send("blocked");
}
return res.redirect(parsed.toString());
});
// If a regular expression is required, escape the dots as follows.
// const ok = /^((static|media)\.)?example\.com$/i.test(host);
Explanation:
- Before: Unescaped dots in
^((static|media).)?example.com$also match other characters. A similar-looking hostname may pass validation, enabling an open redirect or, in a server-request flow, SSRF. - After: The parsed hostname must exactly match the allow-list. Only the default HTTPS port is accepted, and URL credentials are rejected. The commented regular-expression alternative also escapes the dots and uses anchors.
Browser hostname
Before
javascript
function checkLocation(document) {
return /^example.com$/.test(globalThis.location.hostname);
}
This example checks the current page's hostname. Escape the dot so it matches an actual separator.
After
javascript
function checkLocation(document) {
return /^example\.com$/.test(globalThis.location.hostname);
}