DEV Community

Cover image for Stop chasing CVEs: allow-list your API instead published: false
OBI EBUKA DAVID
OBI EBUKA DAVID

Posted on

Stop chasing CVEs: allow-list your API instead published: false

Over 40,000 CVEs were published in 2024, up 38% from 28,818 the year before, which works out to roughly 108 new vulnerabilities every single day (NVD via CyberPress). No team patches at that rate. And the flaw that gets you is often the one without a patch yet.

If your security model is "match the latest bad pattern and hope," you are playing a game you lose by design. Let me show you the two models side by side, then a way to start switching without touching your business logic.

The negative model: match known-bad

A signature WAF (and most naive input filters) works like a denylist. You enumerate what a bad request looks like and block it.

// Negative security: block requests that match known-bad patterns
const BLOCKLIST = [
  /(\bunion\b.*\bselect\b)/i,   // SQLi
  /<script\b/i,                  // XSS
  /\.\.\//,                      // path traversal
];

function filter(req, res, next) {
  const haystack = req.url + JSON.stringify(req.body || {});
  for (const pattern of BLOCKLIST) {
    if (pattern.test(haystack)) {
      return res.status(403).send("blocked");
    }
  }
  next();
}
Enter fullscreen mode Exit fullscreen mode

This has two failure modes, and both are structural, not bugs you can fix:

  1. It is blind to anything not on the list. A zero-day, a novel logic abuse, an attack pattern nobody has catalogued yet: no signature, open door.
  2. It generates false positives on legitimate traffic. Someone writes "I want to union our two select committees" in a comment field and your filter 403s a paying customer.

You spend your life tuning regex, and the attack that lands is the one you never wrote a rule for.

The positive model: allow-list normal

Flip it. Instead of enumerating the infinite set of bad requests, you describe the finite set of good ones. This is positive security, the same idea behind allow-list firewalls and SELinux, applied at the application layer.

For an API, "normal" is describable per route:

  • which methods and paths exist
  • the shape of each request body (fields, types, ranges)
  • who is allowed to call it and what objects they can touch
  • rough rate and sequence (does this call normally follow that one)

Anything outside that envelope gets blocked, no signature required.

// Positive security: describe normal, reject the rest
const BASELINE = {
  "POST /transfers": {
    auth: "user",
    body: {
      amount:   { type: "number", min: 1, max: 50000 },
      currency: { type: "string", enum: ["USD", "EUR"] },
      toAccount:{ type: "string", pattern: /^acct_[a-z0-9]{16}$/ },
    },
  },
};

function enforceBaseline(routeKey, req, res, next) {
  const spec = BASELINE[routeKey];
  if (!spec) return res.status(403).send("route not in baseline");

  for (const [field, rule] of Object.entries(spec.body)) {
    const v = req.body[field];
    if (rule.type === "number" && (v < rule.min || v > rule.max)) {
      return res.status(422).send(`${field} out of range`);
    }
    if (rule.enum && !rule.enum.includes(v)) {
      return res.status(422).send(`${field} not allowed`);
    }
    if (rule.pattern && !rule.pattern.test(v)) {
      return res.status(422).send(`${field} malformed`);
    }
  }
  next();
}
Enter fullscreen mode Exit fullscreen mode

Notice what this catches that the denylist never could: a toAccount that doesn't match your ID format, an amount of 5,000,000 when your app has never legitimately moved more than 50k, a call to a route that isn't supposed to exist. You didn't write a rule for "the attack." You wrote a rule for "us," and the attack fails because it isn't you.

The honest trade-offs

Positive security is not free. Be clear-eyed about the costs:

  • You need a baseline, and a wrong baseline breaks prod. Hand-writing BASELINE objects for a 300-route API is miserable and goes stale the moment someone ships a feature. This is the real reason teams reach for denylists instead: they are easier to bolt on.
  • Legitimate-but-rare behavior looks like an attack. The once-a-quarter admin bulk job, the enterprise customer with a genuinely huge order. If your baseline is too tight, you page yourself at 2am.
  • Learning has to be continuous. An app is a moving target. A baseline captured once is a baseline that is wrong by next sprint.

The way you manage all three is the same: learn the baseline from real traffic instead of writing it by hand, and start in a mode that logs instead of blocks so you can see what you would have caught before you actually catch it.

How a positive-security layer helps

The pattern above is sound, the operational cost is what kills it in practice. That is the gap a positive-security layer fills. It watches your real traffic, learns the per-route baseline for you, and enforces it, so you are not hand-maintaining BASELINE objects forever.

Two properties make it safe to adopt:

  • Observe mode first. It ships blocking nothing. It learns and reports what it would block, so you tune against real data before flipping to enforce.
  • Fail-open. If the layer is unreachable, your app serves traffic as if it weren't there. Security middleware that can take down your API is worse than the risk it mitigates.

With Autogon Shield the wiring is one line, and it starts in observe:

import { shield } from "@autogon/shield";

app.use(shield({ token: process.env.AUTOGON_TOKEN, mode: "observe" }));
// learns your normal per-route behavior, blocks nothing until you say so
Enter fullscreen mode Exit fullscreen mode

You run it in observe for a while, look at what it flagged, then flip mode to enforce when the baseline looks right. The zero-day you never wrote a signature for fails on day zero, because it isn't on the list of things your app does.

Start here: autogon.ai.

Sources

Top comments (0)