What uapi defends against, what it doesn't, and what the operator should do.
- Anonymous remote access. Every endpoint except
/healthz,/openapi.json, and/schema*requires a bearer token. There is no anonymous read path on user resources. - Plain-HTTP credential leak. TLS is mandatory for every non-localhost
request. A non-TLS request from a non-loopback source returns
403 tls_requiredbefore auth runs. - Token guessing. Tokens are 128 random bits (32 hex chars). Server
stores
sha256(salt || ":" || token); the cleartext is shown to the operator exactly once at creation. A linear scan through the token store on each request is acceptable: tokens are few, hashes are fast, and compromising the server's/etc/config/uapiexposes only hashes. - Privilege escalation via HTTP token-mint.
POST /tokensis gated byuapi:tokens:rw, AND the requested scopes must be a strict subset of the caller's own - escalation returns403 scope_escalation_blocked. A compromised low-privilege token cannot mint an admin token over the API. - Stolen-token reuse from elsewhere. Optional
allowed_cidrson a token pins it to a source CIDR list; requests from outside return401 invalid_token. Optionalexpires_atmakes leaked tokens automatically expire. - Audit log tampering during compromise. Each write emits a syslog
NOTICE line carrying request_id + token name + path. Forwarding syslog
to a remote collector (
log_ipin/etc/config/system) is the recommended production setup; a local-only log is destroyable by anyone who roots the box. - Bypassing scope checks via
/raw/./raw/<package>/<id>requires BOTH araw:*:rw|roscope and the matching domain scope independently. A token withraw:rwbut onlyfirewall:rocannot use/raw/firewall/...to mutate firewall sections. - Shell metacharacter injection. Every shell-invoked path (apk install,
init-script reload) validates names against
^[A-Za-z0-9_-]+$(or^[A-Za-z0-9_+][A-Za-z0-9_+.-]*$for packages) before interpolating, and uses--to separate flags from positional arguments. Service names from ucitrack are validated before interpolating into/etc/init.d/<svc>. - Replay attacks on POST under network flakiness.
Idempotency-Keycaches the response for 24 h; a network-blipped retry returns the cached response, not a duplicate side effect. Same key with a different body returns409 idempotency_key_conflict- a request was reused for an unrelated payload. - Resource exhaustion via flood. Per-token rate limit returns
429 too_many_requestswithRetry-After. Default is 100/s burst 200 per token; tunable via/etc/config/uapi. - Bridge-vlan self-lockout. Documented in
CLAUDE.mdand surfaced in the project memory: writing bridge-vlans onbr-lanof the management bridge bricks the router. Use a throwaway bridge for tests; never POSTbridge_vlanson the management bridge. - uhttpd self-lockout.
uhttpd/instancesvalidate refuses any PATCH or PUT on themaininstance that would strip uapi's ownucode_prefixentry - returning422 conflictinstead of leaving uapi unreachable.
- Local root compromise. uapi runs inside uhttpd, which runs as root. An attacker with root on the box already has uci access; the API is a wrapper. We do not defend against in-process attacks once the workload ID is admin.
- Fine-grained side channels. The hash compare in auth is
constant-time (byte-XOR-accumulate via
values.constant_time_equals) and the authorize loop iterates every token regardless of where the match occurs, so the dominant timing channel is closed. Pre-match work (type(t.salt) == "string"checks, hash-function runtime itself) and downstream paths (expiry check, CIDR match, scope lookup) are not constant-time. A determined attacker with low-latency local access could in principle still extract bits, but tokens are 128 random bits and the rate limit + audit log make recovery infeasible in practice. - DoS by sustained legitimate traffic. The rate limit guards per token; the request budget is per uhttpd worker / kernel limits. A determined operator who hands out 10,000 tokens at 100/s each can saturate the box. Don't.
- Compromise of the OpenWrt host network. If the router's management
interface is on a hostile network, no application-layer auth helps. Use
uhttpd's TLS +
allowed_cidrs+ firewall rules to restrict the listen surface.
Hierarchical, deepest-match wins. Syntax: <segment>[:<segment>...]:(rw|ro).
*:rw/*:roare top-level wildcards.- Mid-tree wildcards:
firewall:*:ropermits ro on every firewall subresource but NOT the bare domain.*:rules:ropermits ro on therulessubresource of every domain. At the same depth, an exact segment beats a wildcard segment. rwimpliesro; grantingfirewall:rwdoes NOT also require grantingfirewall:ro.- Same-depth conflict (
firewall:rules:rw+firewall:rules:ro):rwwins. - No matching scope → deny.
| Caller scopes | Allowed | Denied |
|---|---|---|
*:rw |
every endpoint | nothing |
*:ro |
every GET | every write |
firewall:rw |
every firewall endpoint (zones/rules/redirects) | network/*, dhcp/*, system |
firewall:rules:rw |
firewall/rules CRUD |
firewall/zones, firewall/redirects |
firewall:*:ro |
every GET under firewall subresources | bare firewall:rw, firewall/* writes |
firewall:rw + network:ro |
firewall CRUD + network read | network writes |
raw:firewall:rw + firewall:ro |
none of /raw/firewall/... writes (raw needs BOTH) |
nothing escalated |
uapi:tokens:rw |
mint new tokens whose scopes ⊆ caller's | mint tokens that escalate |
The token name is logged on every audit line. Tokens should be named for
their owner (ci-bot, terraform-prod, alice-laptop) so the audit log
identifies the responsible party, not a hash.
uhttpdserves both HTTP and HTTPS.tls_check()permits HTTP only when the request is loopback (127.0.0.1/::1/::ffff:127.0.0.1) OR the marker file/etc/uapi.insecureexists.- The default uhttpd self-signed cert is NOT adequate for production.
Operators should provision a real cert via
acme.sh/luci-app-acme(docs/installation.mdcovers this). - mutual TLS (mTLS) via
tls_client_cert_file/tls_require_client_certis supported as a defense-in-depth layer on top of bearer auth - seedocs/installation.md. - The
/etc/uapi.insecuremarker is for closed-network testing only. Every request that bypasses TLS via the marker emits a NOTICE-leveluapi-insecure-bypass <request_id> <method> <path> status=<n> remote=<addr>syslog line. Monitor for this in production to detect drift.
/etc/config/uapi token sections:
config token 'ci_bot'
option salt 'a1b2c3d4...'
option hash 'e5f6...sha256-of-salt-colon-token'
list scopes '*:rw'
option expires_at '1733000000' # optional
list allowed_cidrs '10.0.0.0/8' # optional
option last_used_at '1732999900' # tracked
option last_used_ip '10.0.0.42' # tracked
- Cleartext token is shown exactly once at creation (
uapi-token createCLI orPOST /tokensHTTP). Never logged. - Salt is 64 random bits (16 hex chars); hash is
sha256(salt || ":" || token). /etc/config/uapiis a conffile, preserved across upgrades and removal.- last-used tracking is throttled to ~1 write/minute per token via a tmpfs
sentinel (
/var/run/uapi-token-update/<token-id>); the wire response never depends on the audit write.
The salt+hash design defends against an offline attacker who reads the
token store. The hash + 16-char salt makes any pre-computed rainbow attack
useless and forces a per-token bruteforce; the token's own 128 bits make
that intractable.
- The token bucket is per-token, file-backed at
/tmp/uapi-ratelimit/. - Under heavy concurrent load against the same token, two forks may race the read-modify-write and one increment may be lost. The worst case is one request's worth of bucket drift, bounded by the burst size.
- The rate limit is not a security control on its own - it's an
abuse-mitigation guardrail. A patient attacker can stay under the
threshold and grind through the API at the steady-state rate. Use
allowed_cidrs(deny by IP) for actual access control.
One syslog line per write at NOTICE:
uapi <request_id> <token_name> AUDIT - <method> <path> <status> [<duration_ms>ms]
One syslog line per 401/403/5xx at WARNING/ERROR:
uapi <request_id> <token_name|-> WARN <code> <method> <path> <status> [<duration_ms>ms]
uapi <request_id> - ERROR <code> <method> <path> <status> [<duration_ms>ms]
One syslog line per insecure-bypass request at NOTICE:
uapi-insecure-bypass <request_id> <method> <path> status=<n> remote=<addr>
- Configure persistent syslog (
log_filein/etc/config/system). The default ringbuffer is volatile. - Forward syslog to a remote collector (
log_ip). A locally-only audit log is destroyable by anyone who roots the box. - Use NTP. Audit timestamps are useless if the clock is wrong.
/healthzcheckstime_sync: ok/degradedbased on uptime > 60s AND current epoch > 1700000000 (a sanity floor). - Pin tokens to source CIDRs.
allowed_cidrsdefends against token theft from outside the management network. - Set token expiry.
expires_in: 90don every minted token forces rotation. The HTTP rotation endpoint (POST /tokens) makes this ergonomic from automation. - Use mutual TLS for service-account workloads (CI, Terraform). It's an independent factor on top of bearer auth and prevents a stolen bearer from being replayed without the cert.
The audit lines above. The standard error envelope's message field is
human-readable English and may name specific failing fields ("src_zone is required") - never sensitive values.
Specifically NOT logged:
- Cleartext token values.
- Cleartext passwords (the
system/passwordendpoint audit-logs the user name and request id, never the password). - TLS certificate or key material.
Security issues should not be filed as public GitHub issues. See the project README for the disclosure path.