The Wasm Component Model on Fastly Compute

Next-Gen WAF: A Verdict You Have to Act On

EDIT (2026-10-02): added an opening on what the Next-Gen WAF is, and what its corps and workspaces are.

A web application firewall, or WAF, examines incoming HTTP requests for signs of an attack, such as a SQL injection payload in a query string, so that dangerous ones can be stopped before they reach your application. Fastly's is the Next-Gen WAF (opens in a new window), which its architecture guide (opens in a new window) calls "an application security monitoring system that proactively monitors and protects your web application from malicious traffic." Its configuration is split between an account, which it calls a corp, and the workspaces inside it, each with its own rules and lists.

A Compute program reaches it through the security interface, which has one function. inspect sends a request to the Next-Gen WAF and gets back its opinion as a JSON string. Under Viceroy, that string is the same 82 bytes every time, and it always says allow.

That's not quite as bad as it sounds, because the WAF never blocks anything itself, even on a real service. It gives you a verdict, and your code decides what to do with it.

The whole interface

WIT
interface security {
  use http-req.{request, body, ip-address, error};

  /// Inspects request HTTP traffic using the [NGWAF] lookaside service.
  ///
  /// Returns a JSON-encoded string.
  ///
  /// [NGWAF]: https://docs.fastly.com/en/ngwaf/ (opens in a new window)
  inspect: func(
    request: borrow<request>,
    body: borrow<body>,
    options: inspect-options,
    max-len: u64
  ) -> result<string, error>;

  /// Configuration for inspecting a `request` using Security.
  record inspect-options {
    corp: option<string>,
    workspace: option<string>,
    override-client-ip: option<ip-address>,

    /// Additional options may be added in the future via this resource type.
    extra: option<borrow<extra-inspect-options>>,
  }

  /// Extensibility for `inspect-options`
  resource extra-inspect-options {}
}

There's one function, and the word to notice in its doc comment is lookaside. The WAF doesn't sit in the request's path the way a proxy would. Your program asks it about a request, off to the side, and carries on with whatever it decides.

That's why both handles are borrowed. inspect looks at the request and its body without taking either, so after asking, you still hold everything you need to forward the request, block it, or redirect it. Compare Image Optimizer, whose request went in by value and never came back.

The options name where to ask. corp and workspace identify the Next-Gen WAF corp and workspace whose rules should judge the request, and override-client-ip lets you inspect on behalf of an address other than the one the request came from, which matters if requests reach you through another proxy. The WIT makes all three optional, and the extra resource is the same forward-compatibility hatch next-request-options carries.

The interface's use line is unusual in one small way. body, ip-address, and error are defined in http-body and types, and security imports all three through http-req instead. It works because a WIT interface re-exports the types it uses, and it's the only interface in compute.wit that reaches for them that way.

What's in the string

"Returns a JSON-encoded string" is all the WIT says. The fields come from the SDKs. The Rust SDK's security module in fastly 0.13.1 deserializes the string into an InspectResponse with five fields:

  • waf_response: a status code the WAF suggests. The struct calls the field response and accepts waf_response as an alias, so either key works.
  • redirect_url: a URL to redirect to, or an empty string.
  • tags: the signals the WAF attached to the request.
  • verdict: allow, block, or unauthorized. Anything else becomes InspectVerdict::Other.
  • decision_ms: how long the decision took.

unauthorized is worth separating from the other two. The SDK describes it as "this service is not authorized to inspect a request." It's a verdict about the configuration, not about the request.

And max-len is the buffer the answer has to fit in. The Rust SDK defaults it to 16 KiB, with a comment that it was "Previously 2048, bumped up after some users got BUFLEN." A verdict with a long list of tags is bigger than the one this post is about to show.

Asking, then deciding

Full working code: full example on GitHub (opens in a new window).

The example makes two calls that should fail, then inspects the request properly. It parses the verdict with serde_json, answers with the WAF's suggested status if the verdict isn't allow, and otherwise forwards the same request to its origin.

Rust

mod bindings;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{backend, http_body, http_resp, security},
};
use serde::Deserialize;

fn respond(status: u16, text: String) -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    response.set_status(status).map_err(|_| ())?;
    response.insert_header("content-type", b"text/plain").map_err(|_| ())?;
    let body = http_body::new().map_err(|_| ())?;
    http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
    http_resp::send_downstream(response, body).map_err(|_| ())
}

/// The verdict document, as the SDKs read it.
#[derive(Deserialize, Debug)]
#[allow(dead_code)] // Some fields are only printed.
struct Verdict {
    #[serde(alias = "response")]
    waf_response: i32,
    redirect_url: String,
    tags: Vec<String>,
    verdict: String,
    decision_ms: u64,
}

fn options(corp: &str, workspace: &str) -> security::InspectOptions<'static> {
    security::InspectOptions {
        corp: Some(corp.to_string()),
        workspace: Some(workspace.to_string()),
        override_client_ip: None,
        extra: None,
    }
}

struct NextGenWaf;

impl http_incoming::Guest for NextGenWaf {
    fn handle(request: http_incoming::Request, request_body: http_body::Body) -> Result<(), ()> {
        let mut lines = String::new();

        // Two calls that should fail: no workspace, and too small a buffer.
        let mut no_workspace = options("my-corp", "my-workspace-id");
        no_workspace.workspace = None;
        let r = security::inspect(&request, &request_body, &no_workspace, 16 * 1024);
        lines.push_str(&format!("no workspace:  {r:?}\n"));
        let r = security::inspect(&request, &request_body, &options("my-corp", "my-workspace-id"), 16);
        lines.push_str(&format!("max-len 16:    {r:?}\n"));

        let json = match security::inspect(&request, &request_body, &options("my-corp", "my-workspace-id"), 16 * 1024) {
            Ok(json) => json,
            Err(e) => return respond(500, format!("{lines}inspect: Err({e:?})\n")),
        };
        lines.push_str(&format!("inspect:       {json}\n"));

        let verdict: Verdict = match serde_json::from_str(&json) {
            Ok(v) => v,
            Err(e) => return respond(500, format!("{lines}unreadable verdict: {e}\n")),
        };
        lines.push_str(&format!("parsed:        {verdict:?}\n"));

        if verdict.verdict != "allow" {
            let status = u16::try_from(verdict.waf_response).unwrap_or(403);
            return respond(status, lines);
        }

        // Inspection only borrowed the request and body, so they're still ours to forward.
        let origin = backend::Backend::open("origin").map_err(|_| ())?;
        request.set_uri("https://http-me.fastly.dev/body=allowed").map_err(|_| ())?;
        request.insert_header("host", b"http-me.fastly.dev").map_err(|_| ())?;
        let (_response, body) = bindings::fastly::compute::http_req::send(request, request_body, &origin).map_err(|_| ())?;
        let mut from_origin = Vec::new();
        while let Ok(chunk) = http_body::read(&body, 4096) {
            if chunk.is_empty() {
                break;
            }
            from_origin.extend(chunk);
        }
        lines.push_str(&format!("origin said:   {}\n", String::from_utf8_lossy(&from_origin)));
        respond(200, lines)
    }
}

bindings::export!(NextGenWaf with_types_in bindings);

The last highlighted block is the payoff of those borrows. The request and body that were just inspected three times are the ones sent to the origin.

One verdict, locally

In production, [setup.products] can enable the Next-Gen WAF on the service the CLI creates, and it needs to know which workspace to link:

TOML
[setup]

  [setup.backends]

    [setup.backends.origin]
      address = "http-me.fastly.dev"
      description = "http-me test origin"
      port = 443

  [setup.products]

    # Enables the Next-Gen WAF on the service and links a workspace. Replace
    # the ID with one of your own. Blocking mode and rules are set up in the
    # workspace itself, not here.
    [setup.products.ngwaf]
      enable = true
      workspace_id = "YOUR_WORKSPACE_ID"

Locally, only the backend is needed, because there's no WAF configuration Viceroy can read:

TOML
[local_server]

  [local_server.backends]

    [local_server.backends.origin]
      url = "https://http-me.fastly.dev"

Start fastly compute serve and send a request:

Terminal output
HTTP/1.1 200 OK
content-type: text/plain
content-length: 312
date: Sun, 13 Sep 2026 14:51:33 GMT

no workspace:  Err(Error::InvalidArgument)
max-len 16:    Err(Error::BufferLen(82))
inspect:       {"waf_response":200,"redirect_url":"","tags":[],"verdict":"allow","decision_ms":0}
parsed:        Verdict { waf_response: 200, redirect_url: "", tags: [], verdict: "allow", decision_ms: 0 }
origin said:   allowed

The first line shows that corp and workspace aren't really optional. Leave either one out, or leave it empty, and Viceroy answers error.invalid-argument. A comment in its source says both are required for now to find the right lookaside service, and that a later version may not need them.

The second shows a well-behaved buffer-len. At a max-len of 16, the call fails and reports 82, which is exactly the length of the verdict on the next line.

Then the verdict itself. Try something that looks like an attack, such as curl "http://127.0.0.1:7676/?id=1%27%20OR%20%271%27=%271", and the inspection line doesn't change. Viceroy's verdict is a constant. It checks that both handles are valid, requires the corp and workspace, enforces max-len, and then returns the same allow document whatever it was given.

What a real service needs

A real verdict takes four things, and each is set up separately: the Next-Gen WAF enabled on the service, a workspace linked to it, that workspace in blocking mode, and rules. [setup.products.ngwaf] covers the first two. Mode and rules are configured in the Next-Gen WAF itself.

Blocking mode and rules are separate requirements, and it's easy to assume one implies the other. A request carrying an obvious attack, such as a SQL injection payload, raises a signal. The signal arrives as a tag, next to a verdict of allow. Only a rule turns signals into a block.

Once all four are in place, a real service returns real verdicts. A block comes back as block with a status of 406. A redirect is also a block, with a 3xx status and a redirect_url.

inspect also wants specific identifiers. corp is the corp's short name, and workspace is the workspace's ID rather than its display name.

One more interaction is worth repeating from the WebSocket passthrough post: Fastly's guide says WebSockets passthrough isn't compatible with the Next-Gen WAF.

Beyond the WIT

inspect is an advisory call. It returns a string, and nothing happens to the request unless your program makes it happen. That puts the decision logic in your code, and it makes Viceroy's constant allow more dangerous than it first appears. Every local run takes the same branch, and none of them ever exercises the code that blocks or redirects.

A binding can make that code testable, and it starts with how the verdict is modeled.

Parse the document into a type, and keep the verdict open-ended. The Rust SDK's InspectVerdict has Allow, Block, and Unauthorized, plus Other(String) for anything it doesn't recognize, the same choice its ACL action type made. A verdict added later arrives as data rather than as a parse failure.

Treat a missing verdict as an error, not a default. An empty or absent verdict isn't allow and it isn't block, and silently defaulting it to either makes a security decision nobody chose. The Rust SDK has an InspectError::NoVerdict for the case where the call succeeds without writing anything.

Encode what makes a suggestion actionable. The Rust SDK only offers a redirect when all three conditions hold: a block verdict, a status from 300 to 309, and a non-empty URL. Its redirect_url() returns nothing unless the verdict is block. One reasonable variation keeps the raw URL visible whatever the verdict, and applies the three-way check only to the helper that builds the redirect. Either way, the conditions live in the binding, where they're written once.

Keep unauthorized visible. It means the service couldn't be judged at all, and whether to fail open or closed in that case is a policy decision for the application. A binding that folds it into allow or block has made that decision without telling anyone.

With a model like that, the part of your program that turns a verdict into a response is a function from a parsed value to an action. You can call it in a unit test with a block, a redirect, an unauthorized, and an unknown verdict, none of which Viceroy can produce, and save the real service for confirming that the configuration produces the verdicts you expect.

That's the last of the interfaces in this run that needed Fastly to switch something on first. For how many of them would you have trusted a green local test run?