The Wasm Component Model on Fastly Compute

ACLs: A Yes-or-No Question With a JSON Answer

On Fastly, an access control list has nothing to do with file permissions. It's a list of IP addresses and ranges, each marked ALLOW or BLOCK, that a service checks a client's address against. Fastly's ACL API reference (opens in a new window) puts it this way: an ACL "specifies individual IP addresses or subnet ranges and can be accessed and used from the Fastly Compute platform."

A lookup in one sounds like it should come back as a boolean. Is this IP address on the list or isn't it? At most you'd expect a small enum, allow or block.

In compute.wit, it comes back as an HTTP body.

The whole interface

WIT
interface acl {
  use types.{error, open-error, ip-address};
  use http-body.{body};

  /// An ACL.
  resource acl {
    /// Opens an ACL linked to the current service with the given link name.
    open: static func(name: string) -> result<acl, open-error>;

    /// Performs a lookup of the given IP address in the ACL.
    ///
    /// If any matches are found, the result is a JSON-encoded HTTP body.
    ///
    /// If no matches are found, then `ok(none)` is returned. This corresponds
    /// to an HTTP error code of 204, “No Content”.
    lookup: func(
      ip-addr: ip-address,
    ) -> result<option<body>, acl-error>;
  }

  /// Errors returned on ACL lookup failure.
  enum acl-error {
    /// Too many requests have been made.
    ///
    /// This corresponds to an HTTP error code of 429, “Too Many Requests”.
    too-many-requests,

    /// Generic error value.
    ///
    /// This means that some unexpected error occurred.
    generic-error,
  }
}

open is the familiar static func, but look at its doc comment: the name is the ACL's link name. An ACL belongs to your Fastly account rather than to any one service, and a service reaches it through a resource link, the same way a Config Store does. The name you pass to open is the link's, which doesn't have to match the ACL's own name.

lookup takes an ip-address, the same variant downstream-client-ip-addr returns, so the client's address goes straight in with no conversion. What comes out is a body. That's http-body's resource, the one The Body Isn't Part of the Request introduced, and you read it with the same read loop you'd use on a backend's response. No match is none.

The error type is different too. Although acl imports the shared error from types, it turns out it never uses it. Like KV Store's kv-error, lookup fails with a dedicated type, acl-error, which has exactly two cases: too many requests, or something unexpected.

The doc comments are describing an HTTP API

Three details in those doc comments point the same way. A match is "a JSON-encoded HTTP body." No match "corresponds to an HTTP error code of 204." Too many lookups "corresponds to an HTTP error code of 429."

Fastly's API for ACLs in Compute (opens in a new window) has an endpoint that looks an IP up in an ACL, GET /resources/acls/{acl_id}/entry/{acl_ip}. Its response is a small JSON object the documentation calls an ACL Lookup:

  • action, a string that the reference says is one of "ALLOW" or "BLOCK".
  • prefix, the matching entry in CIDR notation.

The JSON is the ACL Lookup the reference documents, and the 204 and 429 in the doc comments are the status codes you'd expect from an HTTP endpoint answering the same question. The WIT doesn't define the JSON's fields at all. It hands you the body, and the API reference is where the format is written down.

Looking up an address

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

The example opens an ACL linked as blocklist, looks up the client's IP, or another address you name in an x-test-ip header, and answers 403 for a BLOCK. For contrast, it also tries to open an ACL that doesn't exist.

Rust

mod bindings;

use std::net::IpAddr;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{acl, http_body, http_downstream, http_resp, types},
};

fn to_wit(ip: IpAddr) -> types::IpAddress {
    match ip {
        IpAddr::V4(v4) => {
            let [a, b, c, d] = v4.octets();
            types::IpAddress::Ipv4((a, b, c, d))
        }
        IpAddr::V6(v6) => {
            let [a, b, c, d, e, f, g, h] = v6.segments();
            types::IpAddress::Ipv6((a, b, c, d, e, f, g, h))
        }
    }
}

fn from_wit(ip: &types::IpAddress) -> IpAddr {
    match *ip {
        types::IpAddress::Ipv4((a, b, c, d)) => IpAddr::from([a, b, c, d]),
        types::IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => IpAddr::from([a, b, c, d, e, f, g, h]),
    }
}

fn read_all(body: &http_body::Body) -> Vec<u8> {
    let mut out = Vec::new();
    while let Ok(chunk) = http_body::read(body, 4096) {
        if chunk.is_empty() {
            break;
        }
        out.extend(chunk);
    }
    out
}

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(|_| ())
}

struct Acls;

impl http_incoming::Guest for Acls {
    fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
        // The client's address, unless an x-test-ip header asks about a different one.
        let ip = match request.get_header_value("x-test-ip", 64) {
            Ok(Some(v)) => std::str::from_utf8(&v).ok().and_then(|s| s.parse().ok()).map(to_wit),
            _ => http_downstream::downstream_client_ip_addr(&request),
        };
        let Some(ip) = ip else {
            return respond(400, "no IP address to look up\n".to_string());
        };

        let mut lines = format!("lookup {}\n", from_wit(&ip));

        if let Err(e) = acl::Acl::open("no-such-acl") {
            lines.push_str(&format!("open(\"no-such-acl\") = Err({e:?})\n"));
        }

        let blocklist = acl::Acl::open("blocklist").map_err(|_| ())?;
        let (status, verdict) = match blocklist.lookup(ip) {
            Ok(Some(body)) => {
                let json = String::from_utf8_lossy(&read_all(&body)).into_owned();
                let blocked = json.contains("\"BLOCK\"");
                (if blocked { 403 } else { 200 }, format!("match:\n{json}\n"))
            }
            Ok(None) => (200, "no match\n".to_string()),
            Err(e) => (500, format!("lookup: Err({e:?})\n")),
        };

        lines.push_str(&verdict);
        respond(status, lines)
    }
}

bindings::export!(Acls with_types_in bindings);

Checking for "BLOCK" in the JSON text is a shortcut this example can take because it has no JSON parser. It's not the type of thing a binding should do; more on that below.

The x-test-ip header is there so you can try different addresses locally, and it's a hole in anything real. Any client can send it, so any client can have its lookup done on an address that isn't blocked, 198.51.100.7 for instance, and walk straight past the BLOCK on its own. An access check has to use the address the platform reports, from downstream-client-ip-addr, and never one the request supplies.

An ACL in production, and one in a file

In production, an ACL is created and filled through the API or the CLI, and then linked to the service. The CLI steps look like this:

Bash
fastly compute acl create --name=blocklist
fastly compute acl update --acl-id=<ACL ID> --file=acl-entries.json
fastly service resource-link create --resource-id=<ACL ID> --version=latest --autoclone --name=blocklist

Then activate the new version.

The link matters more than it looks. A link that exists only on an inactive version, or on a version a POP hasn't picked up yet, makes open answer not-found, exactly as if the ACL didn't exist.

For local testing, Viceroy reads the entries from a JSON file, named under [local_server.acls]:

TOML
[local_server]

  # The name here is the one the program passes to acl.open. On a real service,
  # that's the name of the resource link.
  [local_server.acls]
    blocklist = { file = "./acl-entries.json" }

The file uses the same structure as the API's update endpoint, which is why each entry carries an op that Viceroy ignores:

JSON
{
  "entries": [
    { "op": "create", "prefix": "127.0.0.0/8", "action": "ALLOW" },
    { "op": "create", "prefix": "127.0.0.1/32", "action": "BLOCK" },
    { "op": "create", "prefix": "203.0.113.0/24", "action": "BLOCK" }
  ]
}

Two of those entries overlap on purpose. 127.0.0.1 is inside the allowed /8 and also matches a blocked /32 for that one address.

Start fastly compute serve and look yourself up:

Bash
curl -i http://127.0.0.1:7676/
Terminal output
HTTP/1.1 403 Forbidden
content-type: text/plain
content-length: 195
date: Sun, 13 Sep 2026 14:47:13 GMT

lookup 127.0.0.1
open("no-such-acl") = Err(OpenError { code: 3, name: "not-found", message: "No entity by the given name was found." })
match:
{
  "prefix": "127.0.0.1/32",
  "action": "BLOCK"
}

The /32 won. Viceroy returns the most specific matching prefix, the one with the longest mask, and one ACL lookup returns one entry. Ask about a neighbor instead, with curl -H "x-test-ip: 127.0.0.2" http://127.0.0.1:7676/:

Terminal output
lookup 127.0.0.2
open("no-such-acl") = Err(OpenError { code: 3, name: "not-found", message: "No entity by the given name was found." })
match:
{
  "prefix": "127.0.0.0/8",
  "action": "ALLOW"
}

And an address that matches nothing, 198.51.100.7:

Terminal output
lookup 198.51.100.7
open("no-such-acl") = Err(OpenError { code: 3, name: "not-found", message: "No entity by the given name was found." })
no match

That's ok(none), the ABI's version of a 204, which is a normal answer rather than an error. open for an ACL that isn't there fails with open-error.not-found, the variant Config Store enumerated.

Beyond the WIT

Look at the match body again. Viceroy printed it across four lines with indentation. A real service returns the same document compact, on one line. Both are valid JSON with identical content, and they aren't the same text.

That's harmless if you parse it, but it can be a trap if you don't. A test that compares the body to an expected string passes locally and fails against the Compute platform. And the example's contains("\"BLOCK\"") works on both only because neither host happens to put a space inside the quotes. Treating a JSON document as text means depending on formatting nobody promised you.

The contract is the JSON's content, not its formatting, so the first job for a binding is to parse it, and the Rust SDK does. Acl::try_lookup reads the body into a LookupMatch, with a prefix and a MatchAction. The enum's third variant is what's worth copying. The API reference says the action is one of "ALLOW" or "BLOCK", and the SDK still has MatchAction::Other(String), which deserializes anything else instead of failing. An action added later reaches the caller as data, and not as a lookup that suddenly errors. When the only definition of a format lives in documentation outside the ABI, a parser that tolerates growth is safer than one that believes the documentation is complete.

The second job is deciding what to hide. The body-as-answer shape costs a resource handle and a read loop for a document of a few dozen bytes. There's no good reason for a caller to see that. A binding can return the parsed match directly, keep the raw JSON available for anyone who wants a field the parser doesn't know about, and let the body handle stay an implementation detail of how the host delivered the answer.

Next: the last interface in this run. The Next-Gen WAF inspects a request and hands back a verdict, and locally that verdict is always the same one.