Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 19 Oct 02:38
· 65 commits to main since this release

ONRouter v0.4.0 — the project is renamed from LearnON, this carries the nginx/OpenResty parity
campaign that followed v0.3.0, and it is the first release with a documented security audit
(docs/SECURITY_AUDIT.md, findings S1-S8) where every finding was reproduced with real traffic
before it was fixed.

Naming

  • onrouter, orr-gencert, orr-h3check binaries; crates onrouter-config / onrouter-server;
    conf/onrouter.conf; ONROUTER_* env prefix; Server: ONRouter/0.4.0; onrouter_* metric names.

Request path

  • Upgrade (WebSocket) tunnels now pass the ACCESS-phase controls (allow/deny, auth_basic,
    limit_req/limit_conn) and honour proxy_read_timeout as an idle timeout.
  • client_max_body_size defaults to 1m; streamed chunked bodies are length-capped and forwarded
    without copying the whole body; client_body_timeout applies to streamed uploads.
  • Upstream response bodies are read under proxy_read_timeout with the connection guard alive for
    the whole transfer, so passive health checks and metrics count a Content-Length response on its
    last frame.

Proxy / cache / TLS

  • proxy_send_timeout, proxy_next_upstream tries+deadline budgets, balancer_by_lua peer and
    timeout overrides, per-status cache counters, proxy_cache lock/stale/bypass with LRU eviction.
  • Session ticket + ssl_session_cache semantics, ssl_certificate_by_lua, hot cert reload on SIGHUP,
    mTLS to upstreams, ssl_protocols selection with optional --features native-tls for TLS 1.0/1.1.
  • L4 stream {} proxying with ssl_preread SNI routing, connection accounting and graceful drain.

Lua surface

  • receiveuntil, timer coroutines, ngx.sleep/cosocket/ngx.req.socket/ngx.flush, ngx.var
    read+write, ngx.re.*, ngx.balancer, init_worker_by_lua + lua_package_path, bundled
    resty.cookie, resty.limit.{req,count,conn}, resty.http/resty.redis, concurrent subrequests
    with ngx.semaphore, and shared.DICT accounting with make_room.
  • Cooperative preemption: every coroutine resume arms a Lua count hook
    (lua_slice_instructions, default 1 000 000 instructions ~ 10 ms), so a runaway loop can no longer
    blackout the single process.

Observability

  • /metrics gained per-upstream node connections/health/success/failure, cache status, TLS handshake
    failures, limit rejections and dropped access-log lines; the combined log carries $request_time
    plus $upstream_addr / $upstream_status / $upstream_header_time.

Security audit (S1-S8)

Each row was reproduced against the pre-fix binary first, and each has a probe that fails there and
passes here (tools/security_matrix_e2e.py 20/20, tools/rce_matrix_e2e.py 27/27):

  • S1 auth_basic with no user file served the location without any credential — now 401 + error_log.
  • S2 an htpasswd line with an empty hash authenticated on an empty password — such entries are dropped.
  • S3 the htpasswd cache keyed on mtime only, so rewrites that preserved mtime kept revoked users
    logged in — now keyed on (mtime, len) and bounded to 1 s.
  • S4 the Upgrade request line was built from the decoded path (/ws%23frag reached the backend as
    /ws#frag), a routed-vs-forwarded desync — the raw undecoded request target is forwarded now.
  • S5 proxy_request_buffering off skipped client_body_timeout, so a stalled uploader pinned a
    connection and an upstream socket forever — a per-frame Sleep guard rearms on every data frame.
  • S6 any sub_filter made the static handler read the whole file into memory (32 MiB x concurrency,
    switched on by one directive) — only the requested window is read, and above 8 MiB it streams
    unfiltered with a logged reason.
  • S7 while true do end in content_by_lua_block held a VM thread and, because ONRouter is one
    process, unrelated endpoints stopped answering — bounded by the preemption quantum above.
  • S8 run_phases signals errors with true, but every caller tested if !run_phases(...), so a
    missing/broken access_by_lua_file read as "check passed" and the protected body was served with
    200; a streaming content_by_lua_file that failed to load produced an empty 200. Callers
    now terminate on error (nginx parity: an unreadable phase file is a 500, never a pass).

Remote-execution surface

There is no path from client input to code execution: config _by_lua_* bodies and
_by_lua_file paths are config-time literals (no request-variable interpolation, so ?f= cannot
redirect a script path), every lua.load() in the gateway compiles a constant library source, the
debug library is unavailable so mlua's named registry (where cross-request handles live) is
unreachable, there is no process spawn or request-driven file write in the shipped path, no
X-Accel-Redirect, proxy_pass targets cannot be steered by $arg_/request headers (verified
against a canary listener), encoded CR/LF is answered with 400, and internal locations stay
unreachable externally across nine encodings.

Trust boundary, stated plainly: Lua scripts are not sandboxed — os, io, load, loadfile,
dofile, require and package are available inside a script, exactly as in OpenResty. Anyone who
can write a .lua file or the config has code-execution power; and a script that concatenates
ngx.var.arg_* into load() / os.execute() is an application-level RCE the gateway will not
contain. Treat config and scripts as trusted code.

Verification

  • cargo test --workspace 255/255 (242 onrouter-server + 13 onrouter-config).
  • 65 Python E2E suites, all exiting 0 with real traffic against a spawned gateway and fake backends
    (python run_regression.py summaryE.txt writes the exit-code roll-up under logs/regression/);
    highlights: rce_matrix 27/27, security_matrix 20/20, api_compat 57/57, metrics 38/38,
    observability 26/26, lua_flush_socket 23/23, thread 24/24, subrequest 18/18, static_stream 19/19.
  • Windows A/B vs OpenResty (bench_ab.py --sweep 1,4 --requests 12000, 16 logical cores, keep-alive
    GET without TLS): static 1.26x / 2.22x, proxy 2.96x at c=4, Lua 0.71-0.89x of LuaJIT. Read
    docs/OPENRESTY_GAP_ANALYSIS.md section 3 for the caveats — no Linux baseline has been recorded yet.

Assets

Eight cross-platform archives (Linux x86_64/aarch64/armv7/riscv64 as .tar.gz, macOS x86_64/aarch64
and Windows x86_64/aarch64 as .zip), each containing onrouter + orr-gencert, plus
SHA256SUMS.txt. The Windows x86_64 asset was verified by extracting it and driving real requests:
--version reports ONRouter/0.4.0, the response carries Server: ONRouter/0.4.0, and the S8
fail-closed behaviour is present in the packaged binary.

Full Changelog: v0.3.0...v0.4.0