Safeguard
Security•

Runtime guardrails inside your app: what library-level protection catches that a WAF can't

A WAF sees a request, not the SQL query it turns into. How query-structure SQL checks, SSRF checks on resolved IPs, LLM and MCP guardrails, Monitor mode and fail-open work inside the application.

The Safeguard team
9 min read

A web application firewall sees a request. It doesn't see the SQL query that request turns into, or the prompt your code sends to a model. Library-level runtime protection runs inside the application, at the point where input is used, so it can judge an input by what it would actually do there.

This post is for engineers and AppSec teams deciding where runtime checks belong. It explains how query-structure checks for SQL injection work, why SSRF has to be checked against resolved IP addresses, what guardrails for LLM and MCP calls look like, and why the safe default for any in-app check is Monitor mode and fail-open. The examples come from Safeguard Guard, which is in early access, but the ideas apply to any tool that works this way.

What a WAF is good at

Start with the concession, because it's true. A WAF at the edge needs no code change, and it's good at whole classes of problems: known-bad signatures, volumetric abuse, bot traffic and quick virtual patches for a public exploit. Keep it.

Its limit is structural. A WAF judges a request by what it looks like, with no knowledge of where each field ends up.

So it has two bad options for every rule. Make the rule strict and it blocks a support ticket that mentions DROP TABLE. Make it loose and an encoded payload walks past. Either way, it's guessing.

And some traffic never passes through it at all. Calls your application makes outward, to a cloud metadata endpoint, a model provider or an MCP server, start inside the application.

Inside the app, a check sees the sink

A sink is the place where input gets used in a way that matters: a database driver's query call, a child_process.exec, a file open, an outbound HTTP connection, a deserializer, an eval. Library-level protection hooks those calls in the libraries your code already uses. When a sink runs during a request, the check knows two things a WAF can't: the exact string reaching the sink, and which parts of the request it came from.

SQL injection, checked by query structure

Here is the classic mistake, in Express with pg:

// Vulnerable: request input is concatenated into the SQL text
app.get('/orders', async (req, res) => {
  const { rows } = await db.query(
    `SELECT * FROM orders WHERE customer_id = '${req.query.customer}'`
  );
  res.json(rows);
});

// Safe: the input arrives as a bound parameter
app.get('/orders', async (req, res) => {
  const { rows } = await db.query(
    'SELECT * FROM orders WHERE customer_id = $1',
    [req.query.customer]
  );
  res.json(rows);
});

Guard's SQL check is what we call taint-lite. Full taint tracking follows every string through every transformation, and it's expensive. Taint-lite makes one narrower decision, the same way in every language:

  1. When a request arrives, the middleware records the user-controlled strings in it: query values, path segments, form fields, JSON string values, cookies and most headers.
  2. When a query runs during that request, the check looks for any of those strings inside the SQL text.
  3. If it finds one, it tokenises the query twice: once as written, and once with that string replaced by a single placeholder literal.
  4. If the two token streams differ, the input changed the structure of the query. It closed a string literal, or added a keyword, an operator, a comment or a ;. That's SQL injection, by definition.

With ?customer=acme, the token streams match: acme is just a value inside a literal. With ?customer=x' OR '1'='1, they don't, because the input closed the literal and added OR. The parameterised version never triggers, because the input never appears in the SQL text at all.

That's the difference from a signature rule. A support ticket that says DROP TABLE doesn't change any query's structure, so it passes.

Taint-lite has known gaps, and we'd rather state them. It misses input that's transformed before it reaches the query, such as base64-decoded or re-cased strings. It also misses second-order injection, where input is stored first and used in a query later. Signature checks still run on the inbound request to cover some of that ground.

The same approach covers other sinks:

  • Command injection. It triggers when request input containing shell metacharacters reaches a command that runs through a shell, or when request input becomes the program being run.
  • Path traversal. It triggers when a resolved path leaves the root you allowed, or when request input carries .. segments that change where the path points.
  • Code evaluation. It triggers when request input reaches the source passed to eval, an expression engine or a template compiler (as template source, not as a template variable).

Unsafe deserialization

Deserialization bugs don't need injected text. They need the deserializer to instantiate a class it shouldn't. Guard checks at the deserializer itself: allow or deny lists of class and type names, and a rule that flags unsafe loaders. Examples are .NET's BinaryFormatter, Jackson with default typing, and PyYAML's load with a non-safe loader on input carrying Python object tags.

Python's pickle is checked by class rather than blocked outright, because legitimate caches and multiprocessing use it constantly. A rule that blocks normal startup isn't a security control. It's an outage.

SSRF has to be checked at the resolved IP

Server-side request forgery is when an attacker gets your server to make a request for them, usually to something only your server can reach. The prize is often the cloud instance metadata endpoint at 169.254.169.254, which can hand out credentials.

Checking the URL string isn't enough. An attacker can register a hostname that resolves to 169.254.169.254. With DNS rebinding, a name can even resolve to a harmless address when you check it and to the metadata address when you connect.

So Guard checks outbound connections at connect time, against the IP addresses the name actually resolved to. An egress guardrail blocks cloud metadata addresses by default: 169.254.169.254, fd00:ec2::254, metadata.google.internal, 169.254.170.2 and 100.100.100.200. You can add host allow lists, deny lists and CIDR ranges, and optionally block private ranges.

On top of the address check, Guard flags the most common SSRF shape directly. That's request input that names the outbound host (often a whole ?url= parameter) when the destination is internal.

LLM calls and MCP tool calls

AI traffic carries risks a network firewall doesn't look at, and almost all of it starts inside your application. Guard hooks the HTTP calls that LLM client libraries make, such as the OpenAI and Anthropic SDKs, LangChain and other clients for OpenAI-compatible APIs, and applies these checks:

  • Prompt injection. Requests and responses are scored for injection and jailbreak attempts against a threshold you set.
  • Secrets and personal data. Detectors find things like API keys, tokens, card numbers and email addresses in prompts and outputs. A guardrail can block them on the way out to the model, or redact them from a response before it leaves your service.
  • Model allow lists. Only the providers and models you approve, matched by name such as openai/gpt-4o*.
  • Token and cost limits. Per request, or per time window per key.

MCP tool calls get the same treatment. A guardrail can allow, deny or monitor a tool by name, scan tool arguments and results for injection and sensitive data, and limit what an agent calls. One current limit: streamed model output isn't inspected yet.

Monitor first, enforce when you're ready

Every guardrail starts in Monitor mode. In Monitor, a triggered rule changes nothing. It records what it would have done (would_block, would_redact) so you can watch real traffic for false positives before anything is blocked.

Switching to Enforce takes an explicit decision by a person, and you can switch back at any time. A single check inside a policy can be held in Monitor while the rest enforce.

Fail-open, and a kill switch

A security control inside your application can become the reason it goes down. So Guard fails open: if it can't decide, because it has no policy yet, an internal error or an evaluation that runs too long, the request goes through and Guard records that it failed open.

There's also a kill switch. One environment variable turns every hook into a no-op without a code change:

export SAFEGUARD_GUARD_DISABLED=1

Fail-open is a trade-off, and it's the right default. A check that fails closed turns every Guard bug into your incident.

What a decision records, and what it doesn't

Each decision says where a check fired, without the values involved. It records the hook, the library call, the part of the request the input came from, the route template and a few application stack frames. It never records the query, the payload or the value itself. A trimmed example:

{
  "action": "block",
  "outcome": "blocked",
  "matches": [{ "type": "waf", "detector": "sqli:structure", "location": "hook:sql.query", "severity": "high" }],
  "hook": {
    "type": "sql.query",
    "sink": "pg.Client.query",
    "source": "query:customer",
    "route": "/orders",
    "stack": ["app/routes/orders.js:12"]
  }
}

That's enough to find the line of code and fix it, without copying customer data into a security log.

What library-level protection doesn't do

It sees what passes through the libraries and frameworks it hooks. It doesn't hook the kernel or system calls, and it won't see an attack that never touches a hooked library. Go can't be patched at runtime, so Go services use explicit wrappers around the database driver, commands and HTTP client.

And every check costs something on the request path. Guard's in-process checks are designed for sub-millisecond overhead and never make a network call while handling a request. Measure it on your own traffic before you enforce.

Where Guard is today

Safeguard Guard is in early access, and it hasn't been tested by customers yet. Its SDK packages are rolling out to registry.safeguard.sh over the coming days. Once they're available, Guard installs as one line of middleware in Node.js, Python, Java, Go and .NET applications, and enforces the guardrails you define on the Safeguard Policy page. The next post walks through that path end to end, from policy to signed bundle to middleware.

More on the product: Safeguard Guard. Pricing is available on request. To request early access, book a call.

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.