Skip to content

[origins] Weak Referer / Origin validation

This check looks for common mistakes when using $http_origin or $http_referer to gate security behavior (CORS, clickjacking headers, etc.). It covers brittle regex-based validation in if conditions and map-based CORS allowlists, as well as configurations that reflect the request Origin into Access-Control-Allow-Origin unconditionally.

What it detects

Regex that can be bypassed to match an untrusted domain

Examples of bypasses this plugin tries to find:

  • Suffix injection: https://good.example.com.evil.com
  • Prefix injection: http://evil.com/?https://good.example.com
  • Scheme confusion (when you meant to require https): http://good.example.com

Invalid values that should never be treated as a valid Origin/Referer

The plugin reports patterns that accept values that are syntactically invalid for the header being validated:

  • Origin must be: <scheme>://<hostname>[:port] (no path, query, or fragment).
  • Referer should be an absolute URL including scheme and hostname.

It can also flag values that contain uppercase letters or unusual characters in the scheme/host.

Common header typo

$http_referrer is not a valid NGINX variable for the HTTP Referer header. The correct variable is $http_referer.

Unconditional reflection of the Origin

Reflecting $http_origin straight back into Access-Control-Allow-Origin accepts any origin — there is no allowlist at all. The plugin flags this whether the header is emitted with add_header or more_set_headers, and whether the request Origin is reflected directly, through one or more set directives, or via a non-regex map default:

# Direct reflection — every origin is allowed
add_header Access-Control-Allow-Origin $http_origin always;

# The same header, emitted by the headers-more module
more_set_headers "Access-Control-Allow-Origin: $http_origin";

# Indirect, via set
set $cors $http_origin;
add_header Access-Control-Allow-Origin $cors always;

# Indirect, via a chain of set directives
set $tmp $http_origin;
set $cors $tmp;
add_header Access-Control-Allow-Origin $cors always;

# Non-regex map that defaults to reflecting the origin
map $http_origin $cors {
    default $http_origin;
}
add_header Access-Control-Allow-Origin $cors always;

Only hops whose value is a single variable are followed (set $cors $tmp;, default $tmp;); a map hop is followed only when the map is keyed on $http_origin. Only set directives that can run for the request are considered: those in the same server block and in the enclosing location chain, including their if blocks. A set in another server or in a sibling location is not followed, while a set in any location counts for a header emitted at server level, because that header is inherited into every location. A literal-key map allowlist (for example https://trusted.example $http_origin; with a safe default) is not flagged: only the listed origins are echoed back. Like the map analysis below, this detection runs only on a full configuration scan (one that includes an http { .. } block). Regex map entries whose value is empty or fixed (for example ~.* ""; or ~.* https://trusted.example;) are skipped as well, since they never echo the request Origin.

Reflection inside an if that checks the Origin first is also exempt, as long as that check is a real allowlist: an exact match such as if ($http_origin = https://trusted.example), or a positive regex (~, ~*) that passes the regex checks above. The same exemption applies to a set that copies the Origin inside such an if. A guard the plugin reports itself does not restrict anything, so reflection inside it is still flagged:

# Still flagged: any https origin passes the check and is reflected
if ($http_origin ~* "^https://") {
    add_header Access-Control-Allow-Origin $http_origin always;
}

# Not flagged: only https://trusted.example and its www subdomain are reflected
if ($http_origin ~* "^https://(www\.)?trusted\.example$") {
    add_header Access-Control-Allow-Origin $http_origin always;
}

# Not flagged: $cors is only assigned for the allowlisted origin
if ($http_origin ~ "^https://trusted\.example$") {
    set $cors $http_origin;
}
add_header Access-Control-Allow-Origin $cors always;

Negative matches (!~, !~*) and conditions on other variables do not count as guards.

Why this is a problem

Origin and Referer are attacker-controlled request headers. If your config uses them to decide whether to:

  • set Access-Control-Allow-Origin,
  • set X-Frame-Options / Content-Security-Policy: frame-ancestors,
  • enable credentials (Access-Control-Allow-Credentials: true),

then a slightly-wrong regex can silently turn a strict policy into "trust anything that looks kind of right".

Regex allowlists are especially easy to get wrong when you combine:

  • alternation (|),
  • partial anchoring (^ without $, or vice versa),
  • match-any-character dots (.),
  • optional groups,
  • subdomain handling,
  • ports,
  • scheme handling.

What triggers a finding

You will typically see findings in these patterns:

if-based validation

# Intended: allow only yandex.ru
# Risk: also matches https://metrika-hacked-yandex.ru/
if ($http_referer !~ "^https://([^/])+metrika.*yandex\.ru/") {
    add_header X-Frame-Options SAMEORIGIN;
}

The above case is an example of a MEDIUM finding. The referer regex can be bypassed to match an untrusted domain.

# Invalid for Origin: origin cannot contain a path
if ($http_origin !~ "^https://yandex\.ru/$") {
    add_header X-Frame-Options SAMEORIGIN;
}

The above example is a LOW finding. The regex matches an invalid Origin (the Origin must not include a path, even /).

# Wrong variable name (typo)
if ($http_referrer !~ "^https://yandex\.ru/") {
    add_header X-Frame-Options SAMEORIGIN;
}

The above example is a HIGH finding. The config is using the wrong variable (referer vs. referrer).

map-based CORS allowlists that reflect an origin

The plugin also inspects this common pattern:

# Invalid origin reflected: matches subdomain5example.com
map $http_origin $allow_origin {
    default "";
    ~^https://subdomain.example.com$ $http_origin;
}

add_header Access-Control-Allow-Origin $allow_origin always;

If the map regex can be bypassed (or matches invalid Origin forms), you can end up reflecting a hostile origin.

Note: scanning of this pattern (defining access-control-allow-origin based on a map) occurs only when a full configuration is performed, i.e. when the configuration scanned includes an http { .. } block.

Bad configuration

# Intended to allow only yandex domains, but can also match:
# https://www.yandex.ru.evil.com
if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)$)) {
    add_header Access-Control-Allow-Origin "$http_origin";
    add_header Access-Control-Allow-Credentials "true";
}

Common issues here:

  • alternation with uneven anchoring,
  • missing $ anchors,
  • reflecting $http_origin directly when the allowlist is not strict.

Safer configuration patterns

Prefer map with a strict allowlist and controlled reflection

map $http_origin $allow_origin {
    default "";

    # Allow example.com and any subdomain, optional port, https only.
    "~^https://([a-z0-9-]+\.)?example\.com(?::[0-9]{1,5})?$" $http_origin;
}

add_header Access-Control-Allow-Origin $allow_origin always;
add_header Access-Control-Allow-Credentials "true" always;

This is better because:

  • only allowlisted origins are reflected,
  • everything else becomes an empty value,
  • the pattern is fully anchored and describes the full Origin syntax.

Keep Origin rules strict (Origin has no path)

If you need to validate Origin, always anchor the entire value, including any optional port.

Good structure to aim for:

  • ^https://
  • optional subdomain
  • exact registrable domain
  • optional :port
  • $

Notes for Referer validation

If your goal is anti-hotlinking or basic referer checks, consider using valid_referers (see the ngx_http_referer_module documentation) instead of hand-rolled regex in if. It is not perfect, but it is easier to audit than ad-hoc patterns.

Configuration

This plugin has a few knobs you can use to decide how strict you want it to be.

domains

You can use the domains option to define a trusted allowlist of registrable domains. If the regex can be bypassed to match a different domain, the plugin will flag it as insecure.

By default, this is *, which disables domain allowlisting checks.

CLI

# Only treat origins/referers under example.com and example.org as trusted
gixy --origins-domains "example.com,example.org"

Config

[origins]
; allowlist trusted registrable domains (use "*" to disable allowlisting)
domains = example.com,example.org

https-only

You can use the https-only option to require the https scheme. When enabled, patterns that allow http:// will be flagged as insecure.

By default, this is false.

CLI

# Only allow https origins/referers
gixy --origins-https-only true

Config

[origins]
; require https scheme in origins/referers
https-only = true

lower-hostname

You can use the lower-hostname option to enforce lowercase scheme/hostname expectations. When enabled, patterns that accept uppercase or unusual characters in the scheme/host are treated as invalid.

By default, this is true. Only disable this if you really know what you're doing (hostnames are nearly always case insensitive!)

CLI

# Disable lowercase validation
gixy --origins-lower-hostname false

Config

[origins]
; enforce lowercase scheme/hostname checks
lower-hostname = true

Additional notes

This plugin uses different severities depending on what it finds, and which header is involved. The logic is generally quite simple:

  • If the regex matches an insecure Origin, the severity is HIGH.
  • If the regex matches an invalid Origin or Referer, the severity is LOW.
  • If the regex matches an insecure Referer, the severity is MEDIUM.
  • If $http_referrer is used, the severity is HIGH.

The check for the invalid map-based CORS header is only performed when a full configuration scan occurs, i.e. when the configuration scanned includes an http { .. } block.

NGINX matches these patterns with PCRE, while gixy analyses them with its own parser. Atomic groups ((?>...)) and possessive quantifiers (a++) are analysed like their non-capturing and greedy equivalents. A pattern using PCRE-only syntax gixy cannot parse — branch reset (?|...), recursion (?R) — is skipped with a warning on stderr rather than analysed. Such a pattern is never reported, so a warning means the origin or referer check did not run for that regex.