Safeguard
Application Security•

ASVS 5.0 V3.5.2: Make Sure Sensitive Calls Always Trigger Preflight

ASVS V3.5.2 (Level 1): if CORS preflight protects your API, an attacker must not be able to reach it with a simple request. Check Content-Type and Origin. Code and tests.

Safeguard Security Team
3 min read

ASVS 5.0 requirement V3.5.2 says that if your application relies on CORS preflight to stop disallowed cross-origin use of sensitive functionality, it must be impossible to call that functionality with a request that does not trigger a preflight. That may mean checking the Origin and Content-Type headers, or requiring an extra header that is not CORS-safelisted. It is a Level 1 requirement.

Why it matters

Preflight is an OPTIONS request the browser sends before certain cross-origin requests, asking whether the real request is allowed. It is only sent for non-simple requests: methods other than GET, HEAD and POST, custom headers, or content types other than application/x-www-form-urlencoded, multipart/form-data and text/plain.

Here is the trap. A JSON API expects Content-Type: application/json, which triggers a preflight. But if the server parses the body no matter what the content type says, an attacker's page can send the same JSON as text/plain in a plain fetch or form post. No preflight is sent, the request carries the victim's cookies, and the action runs.

How to implement it

Enforce the content type strictly, and fail anything else:

app.use("/api", (req, res, next) => {
  if (["POST", "PUT", "PATCH"].includes(req.method) && !req.is("application/json")) {
    return res.status(415).json({ error: "content type must be application/json" });
  }
  next();
});
app.use(express.json({ type: "application/json" }));

Flask:

@app.before_request
def require_json():
    if request.method in ("POST", "PUT", "PATCH") and request.mimetype != "application/json":
        abort(415)

Add a second guard: require a custom header, for example X-Requested-With, and validate the Origin header against your allowlist for state changing requests. Do not parse form encoded bodies on JSON endpoints at all, because frameworks that merge them make the bypass easy.

How to test it

Replay a sensitive JSON request with a simple content type:

curl -si -X POST https://api.example.com/v1/transfer -b cookies.txt \
  -H "Content-Type: text/plain" -H "Origin: https://evil.example" \
  -d '{"to":"acct2","amount":1}'
curl -si -X POST https://api.example.com/v1/transfer -b cookies.txt \
  -H "Content-Type: application/x-www-form-urlencoded" -d "to=acct2&amount=1"

Both must be rejected with 415 or 403, and no transfer made. Also test without any content type and with a charset suffix. Then send a request from a foreign Origin without the custom header and confirm it is refused.

Common mistakes

Parsing any body as JSON. Some middleware ignores the declared type.

Allowing GET for state changes. GET never preflights.

Trusting CORS headers alone. They govern reads, not whether the request executes.

Quick check

Which content types avoid preflight?

application/x-www-form-urlencoded, multipart/form-data and text/plain. Anything else, such as application/json, triggers one.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.