Incomplete hostname regular expression: unescaped dot

Incomplete hostname regular expression with an unescaped dot

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' or host.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);
}

References