DEV Community

Sourav Sarkar
Sourav Sarkar

Posted on

When a CORS Error Was Really a WordPress Scanner

How a public API turned WordPress scanner traffic into noisy CORS 500s—and how we stopped it at Nginx and Express.

Our Sales AI backend suddenly started producing production alerts like this:

Error: Origin not allowed: https://saleaiback.example.com
Enter fullscreen mode Exit fullscreen mode

At first glance, it looked like a frontend CORS regression. The frontend was allowed, the backend was healthy, and the domain was configured for HTTPS. So why was production reporting errors for the backend's own hostname?

The short answer: it was not a frontend failure. It was an internet scanner probing WordPress endpoints on a public API hostname.

This post walks through the investigation, the subtle distinction between a request URL and its Origin header, and the two-layer fix that turned false production alarms into quiet, blocked traffic.

The setup

The API ran behind Nginx and used an explicit CORS allowlist:

ALLOWED_ORIGINS=https://sales-dashboard.example.com
PUBLIC_URL=https://sales-api.example.com
Enter fullscreen mode Exit fullscreen mode

The application accepted browser calls only from the dashboard:

const allowedOrigins = env.ALLOWED_ORIGINS
  .split(',')
  .map((origin) => origin.trim())
  .filter(Boolean);

app.use(
  cors({
    origin: (origin, callback) => {
      if (!origin) return callback(null, true);
      if (allowedOrigins.includes(origin)) return callback(null, true);
      callback(new Error(`Origin not allowed: ${origin}`));
    },
    credentials: true,
  })
);
Enter fullscreen mode Exit fullscreen mode

That policy was correct. The problem was the way an expected rejection was handled.

What the logs actually showed

We correlated the Node container logs with Nginx access logs at the same UTC second. The requests were not API routes used by our dashboard. They were variants of common WordPress probe paths:

POST /wp/wp-json/batch/v1
POST /wp/index.php?rest_route=/batch/v1
POST /wp/wordpress/wp-json/batch/v1
Enter fullscreen mode Exit fullscreen mode

They also included fake WordPress-looking referers and changing X-Forwarded-For values. That is scanner behavior, not a browser using our product.

The actual remote IP in an Nginx access log is the first field. Do not trust an attacker-controlled X-Forwarded-For value unless it was set by a proxy you trust.

The part that is easy to misunderstand: URL vs. Origin

These two fields are independent:

Request URL:   https://sales-api.example.com/wp/wp-json/batch/v1
Origin header: https://sales-api.example.com
Enter fullscreen mode Exit fullscreen mode

The request URL says where the request goes. The Origin header says which web origin claims to have initiated it.

The backend was not configured to allow its own hostname as a browser origin. It allowed only the dashboard origin:

https://sales-dashboard.example.com
Enter fullscreen mode Exit fullscreen mode

Therefore both of these were correctly rejected:

Origin: http://sales-api.example.com
Origin: https://sales-api.example.com
Enter fullscreen mode Exit fullscreen mode

The scheme matters because origins are exact, but the deeper issue was the hostname: neither backend origin was in the allowlist, nor should it have been for this application.

Nginx was already doing the right thing for inbound HTTP requests: it redirected port 80 to HTTPS. The scanner could still send an arbitrary Origin header on a request that ultimately reached the HTTPS server.

flowchart LR
  S[Public scanner] -->|POST WordPress probe\nOrigin: sales-api.example.com| N[Nginx]
  N -->|proxy request and headers| E[Express API]
  E --> C{Origin matches\nallowed dashboard?}
  C -->|No| X[Old behavior:\nthrow Error → 500 → alert]
  C -->|Yes| R[Continue to route]

Why an expected CORS rejection became a production alarm

The CORS middleware ran before request logging and route matching. Calling callback(new Error(...)) passed an error to Express's default error handler. That caused three bad outcomes:

  1. The request never matched its route—which was good.
  2. Express logged an error stack and returned 500—which was misleading.
  3. Our 5xx/error alerting treated hostile scanner traffic as a production incident.

The scanner did not reach a WordPress endpoint. There was no WordPress endpoint. But it still created unnecessary operational noise.

Fix 1: reject WordPress probes at the edge

This API does not serve WordPress. We added an Nginx rule before the general proxy location:

server {
    server_name sales-api.example.com;

    # This service is not WordPress. Do not proxy common probe paths.
    location ~* ^/(?:wp(?:/|$)|wordpress(?:/|$)|wp-json(?:/|$)|blog/wp-json(?:/|$)|xmlrpc\.php$) {
        access_log off;
        log_not_found off;
        return 404;
    }

    location / {
        proxy_pass http://127.0.0.1:4000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
Enter fullscreen mode Exit fullscreen mode

Always validate before reloading Nginx:

sudo nginx -t
sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

This rule is intentionally narrow. It protects only routes that our API does not own. Do not add broad catch-all rules without checking your real routes first.

flowchart LR
  S[WordPress scanner] -->|POST /wp/...| N[Nginx]
  N -->|404| Stop[Stopped at edge]
  Stop -. no proxy .-> E[Express API]

After the change, a matching probe returned 404 directly from Nginx and never appeared in backend logs.

Fix 2: make every rejected origin a 403, not a 500

Edge rules handle known noise. They should not be the only protection: scanners can request arbitrary paths, and misconfigured clients happen.

We kept the strict allowlist, but added a narrow Express error handler immediately after the CORS middleware:

import express, { type ErrorRequestHandler } from 'express';

const CORS_ORIGIN_REJECTION_PREFIX = 'Origin not allowed: ';

const corsErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
  if (err instanceof Error && err.message.startsWith(CORS_ORIGIN_REJECTION_PREFIX)) {
    logger.warn(
      {
        origin: req.get('origin'),
        method: req.method,
        path: req.originalUrl,
        ip: req.ip,
        userAgent: req.get('user-agent'),
      },
      '[CORS] rejected request'
    );

    res.status(403).json({ error: 'Origin not allowed' });
    return;
  }

  next(err);
};

app.use(corsErrorHandler);
Enter fullscreen mode Exit fullscreen mode

Now the behavior is explicit:

flowchart LR
  Client[Unknown client or scanner] -->|Bad Origin| CORS[Strict CORS allowlist]
  CORS --> H[CORS error handler]
  H -->|warning with method, path, IP, user agent| L[Structured logs]
  H -->|403 Forbidden| Client
  H -. never reaches .-> Route[Application route]

The request is still blocked. The difference is operationally important: a rejected origin is a client-side access failure, not a server failure.

What we did not do

We did not add the backend hostname to ALLOWED_ORIGINS

That would only silence this one symptom and weaken the intent of the policy. The backend is not the browser application that should be calling the API.

We did not use * in production

Wildcard CORS and credentialed requests are a dangerous combination. Keep an explicit allowlist for browser clients.

We did not rely on blocking one scanner IP

IPs change. The useful long-term controls are route-level filtering, application-level 403 handling, and sensible alert rules.

A small verification checklist

Use a non-production environment when testing rejected origins. You do not need to manufacture production errors to prove the fix.

# Confirm the known scanner path stops at Nginx.
curl -I https://sales-api.example.com/wp/wp-json/batch/v1
# Expected: 404 Not Found
Enter fullscreen mode Exit fullscreen mode

Then confirm the application behavior in staging with an intentionally unapproved origin:

curl -i \
  -H 'Origin: https://unapproved.example.com' \
  https://staging-sales-api.example.com/health
# Expected: 403 Forbidden
Enter fullscreen mode Exit fullscreen mode

Finally, verify that your alert policy treats unexpected 5xx responses as incidents, while rejected origins are warning-level security telemetry that can be sampled or rate-alerted separately.

Takeaways

  1. A CORS error can be scanner noise, not a frontend outage.
  2. The request URL and Origin header are different inputs. Never infer one from the other.
  3. Keep CORS allowlists strict; do not add an origin just to quiet a scanner.
  4. Stop known irrelevant probes at the reverse proxy.
  5. Treat blocked requests as 403 warnings, not 500 server errors.

The best outcome was not to accept the scanner's request. It was to reject it early, clearly, and quietly.

Top comments (0)