Access Control examples

Your question

Are the Access Control examples from the website working in version 0.16? I tried to test them, but anyway, all requests return “deny”. For example, I added this rule:

{
  "allowedEndpoints": {
    "match": [
      {"if": "starts_with(url_path, '/api') && remote_ip != '192.168.122.1'", "then": "403"}
    ],
    "else": "200"
  }
}

or

{
  "allowedEndpoints": {
    "match": [
      {"if": "starts_with(url_path, '/api')", "then": "403"}
    ],
    "else": "200"
  }
}

However, any requested URL gets a 403 error.

2026-05-23T04:44:25Z DEBUG HTTP connection started (http.connection-start) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 39220
2026-05-23T04:44:25Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 39220, remoteIp = 192.168.122.1, url = "/admin"
2026-05-23T04:44:25Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 39220, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(403)"
2026-05-23T04:44:25Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 39220, contents = "{"type":"about:blank","title":"Forbidden","status":403,"detail":"Forbidden"}", code = 403, size = 76
2026-05-23T04:44:25Z DEBUG HTTP connection ended (http.connection-end) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 39220, elapsed = 0ms

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

The issue is that your expression uses url_path but the correct variable name is path. Looking at the debug log, the expression IS matching (result = "Integer(403)"), but it’s matching incorrectly because url_path is not a valid variable, which likely evaluates to an empty/falsy value, causing starts_with(url_path, '/api') to behave unexpectedly - or more likely, url_path evaluates as truthy/non-empty by default.

The correct variable for the request path is path, not url_path. The HttpVariable reference docs list:

Variable Description
path path
url url
remote_ip remote_ip

The docs examples on the Access Control page also use url_path in the examples - it’s worth noting the docs use url_path in the examples but the variable reference only lists path. There may be a discrepancy between the docs and what the server actually exposes.

What to try:

  1. Use path instead of url_path:
{
  "allowedEndpoints": {
    "match": [
      {"if": "starts_with(path, '/api') && remote_ip != '192.168.122.1'", "then": "403"}
    ],
    "else": "200"
  }
}
  1. If the docs examples genuinely use url_path but the variable reference says path, there may be an inconsistency in the documentation or url_path may be an alias - a human contributor can confirm which is authoritative.

Also note from the debug log: the remoteIp shown in the log line is 127.0.0.1 (the local connection), while 192.168.122.1 is the forwarded IP. If you’re behind a reverse proxy, remote_ip may resolve to 127.0.0.1 rather than the client’s real IP, which could explain why your IP check condition never matches as expected.

Reference: HTTP Access Control docs and HttpVariable reference.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

@helpbot ok, try to

{
  "allowedEndpoints": {
    "match": [
      {"if": "starts_with(path, '/api')", "then": "403"}
    ],
    "else": "200"
  }
}

Result

2026-05-23T05:36:00Z DEBUG JMAP method call (jmap.method-call) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60578, id = "x:Action/set", accountId = 1, elapsed = 16ms
2026-05-23T05:36:00Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60578, contents = "{"methodResponses":[["x:Action/set",{"accountId":"b","created":{"action-0":{"id":"bvbcpga"}}},"0"]],"sessionState":"921da268"}", code = 200, size = 126
2026-05-23T05:36:00Z DEBUG HTTP connection ended (http.connection-end) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60578, elapsed = 18ms
2026-05-23T05:36:02Z DEBUG HTTP connection started (http.connection-start) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60580
2026-05-23T05:36:02Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60580, remoteIp = 192.168.122.1, url = "/admin"
2026-05-23T05:36:02Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60580, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(403)"
2026-05-23T05:36:02Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60580, contents = "{"type":"about:blank","title":"Forbidden","status":403,"detail":"Forbidden"}", code = 403, size = 76
2026-05-23T05:36:02Z DEBUG HTTP connection ended (http.connection-end) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 60580, elapsed = 1ms

Looking at the log carefully, the request URL is /admin - not /api/*. Yet starts_with(path, '/api') is returning Integer(403). That’s very strange and suggests path may also not be the right variable, or there’s something else going on.

Let me check the actual source code to see what variable names are really available.

Now I can see exactly what’s happening. Looking at the log carefully:

url = "/admin"
result = "Integer(403)"

The request is to /admin, and it’s returning 403. The variable is path (confirmed in the source), and starts_with(path, '/api') should NOT match /admin. But the log says it evaluates to 403 anyway.

Looking more carefully at the log: the URL is /admin which does NOT start with /api, so the else branch should return 200 – but it’s returning 403. This means the rule is working backwards from what you expect.

The key insight from the source code is that remote_ip resolves to self.session.remote_ip – which is the raw socket IP (127.0.0.1 in your case, as seen in the log), NOT the forwarded IP. The forwarded IP (192.168.122.1) shown in the log is from a different field (proxy protocol / X-Forwarded-For).

But more importantly: your second test rule starts_with(path, '/api') should only block /api* paths and return 200 for /admin. Yet you’re still getting 403. This means either:

  1. The rule wasn’t saved correctly - try refreshing/re-reading the config to confirm the new rule is actually active.
  2. There’s a caching/reload issue - the old rule (which matched everything) is still in effect.

Check by looking at the eval.result log line - add a request to an /api path and see if that also returns 403 (expected) vs. a non-/api path returning 200 (also expected). In your log above, the request is to /admin returning 403, which should NOT match starts_with(path, '/api') - the rule appears to still be the old one.

Also confirmed from source: url_path is not a valid variable - it resolves to Variable::default() (empty/zero). The correct variable is path. The docs examples using url_path are incorrect and this is a documentation bug.

Reference: HTTP Access Control docs and HttpVariable reference.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

@helpbot

1 = Saved and reloaded

2 = Not possible. Tested on a VM, so it was restored from a snapshot.

Let me look more carefully at the source code to understand why /admin is matching the starts_with(path, '/api') rule.

I found the critical piece. Look at this code from request.rs (lines 71-79):

match ctx.has_endpoint_access(self).await {
    StatusCode::OK => (),
    status => {
        // Allow loopback address to avoid lockouts
        if !session.remote_ip.is_loopback() {
            return Ok(JsonProblemResponse(status).into_http_response());
        }
    }
}

This is the key: when an endpoint is denied (non-200 status), the server checks session.remote_ip.is_loopback(). If the socket-level remote IP is loopback (127.0.0.1), the denial is silently bypassed to prevent lockouts.

In your logs, remoteIp = 127.0.0.1 at the socket level. So even though your expression correctly returns Integer(403) for /admin, the lockout-prevention code sees the loopback IP and allows the request through anyway… wait, but you’re seeing 403, not 200. Let me re-read.

Actually looking again at your latest log: the /admin request IS returning 403. But the JMAP request at the top (to /jmap presumably) returned 200. So starts_with(path, '/api') is working correctly – /admin starts with /admin not /api, yet it’s 403.

Wait – look again at the log. The first successful request (200) is a JMAP call on port 60578. The 403 is a separate connection on port 60580, going to /admin. Your rule starts_with(path, '/api') should NOT match /admin. But it’s still returning 403.

The most likely explanation: the expression is evaluating the full url variable (which includes query string) rather than path, or there’s a different rule still active. But more importantly – the loopback bypass should be kicking in since remoteIp = 127.0.0.1… unless the remote_ip in session is the forwarded IP (192.168.122.1), not the socket IP.

Look at the source again: the bypass uses session.remote_ip.is_loopback(). If the server is resolving proxy-forwarded IPs into session.remote_ip, then session.remote_ip = 192.168.122.1 (not loopback), and the bypass does NOT apply. That means your rule is blocking /admin for 192.168.122.1 – but /admin doesn’t start with /api!

This points to the expression cache not having updated. Since you confirmed it’s a fresh snapshot restore, please double-check what the currently-active expression actually is by checking the WebUI directly (Settings > Network > HTTP > Security, read the allowedEndpoints field). The log line id = "Http with id singleton" confirms it’s reading from the Http singleton – verify the saved value there is truly the new path-based rule.

Also: could you share what URL you use to reach /admin? If you’re accessing it via a path like /admin but there’s a redirect or rewrite, the evaluated path might differ.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

@helpbot Regarding the snapshot and system rollback, I perform a rollback, then add {"starts_with(path, '/api')", "then": "403"}, but after a reload, every URI returns a 403 error, including /admin/login, for example."

It seems like the expression is matching everything globally or the rule structure itself blocks non-matching paths by default*.*

The variable name in HTTP-context expressions is path, not
url_path. Docs are wrong here; I’ll fix the access-control and
variables pages. url_path resolves to empty, so your first rule’s
starts_with(url_path, '/api') was always false and you hit the
deny in the else.

After switching to path, the second attempt should return 200 for
/admin. The fact that it still returns 403 likely means the rule
isn’t actually in effect: a quick check is to confirm via the API
that the singleton contains your new expression, and that the server
picked it up (the change should apply on save without a restart, but
worth verifying).