Summary
The management REST API defaults to 0.0.0.0:3001 with no authentication on any route, and POST /app feeds caller-supplied bytes straight into a dlopen inside the controller process. For the documented single-host usage (jrtc-ctl against localhost:3001) nothing is exposed; but the defaults an integrator inherits before wiring a management framework are bind-all-interfaces with anonymous app loading. Two asks: safest-by-default binding plus an optional auth token — and one small memory-safety fix found next to it.
1. Unauthenticated management API on all interfaces, feeding an unsigned native-code load path
In src/rest_api_lib/src/lib.rs the router has no authentication or authorization middleware on any route, and the server binds every interface:
let app = Router::new()
.route("/app", get(get_apps).post(load_app))
.route("/app/:id", get(get_app).delete(unload_app))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
load_app takes the base64 app bytes from POST /app and passes them to the C load_app callback (src/controller/jrtc_int.c L300-335), which writes them to a memfd and calls dlopen("/proc/self/fd/N", RTLD_LAZY) (_jrtc_load_app_from_memory, src/controller/jrtc_int.c L149-176). There is no signature, hash, or allowlist check anywhere on this path.
Consequence: any host that can reach port 3001 can load arbitrary native code into the controller process — the process holding the jbpf shared-memory telemetry and control channels to the instrumented network functions. On a testbed where the controller and the RAN workloads share a bridge network, that is every peer on the bridge. The app_type: "python" route reaches a similar outcome: REST-supplied paths are appended to sys.path and REST-supplied modules are imported (src/pythonapp_loader/jrtc_pythonapp_loader.c L161-169, L357-367).
The README positions the management plane as integrator territory — jrtc-ctl "can be optionally integrated with other management frameworks (e.g. nRT-RIC or SMO in O-RAN terminology)". That is exactly why the built-in defaults should be safe without such a framework: a deployment that has not integrated one inherits today's bind-everywhere, no-auth defaults, and startup prints listening on 0.0.0.0:3001 with no indication that the port loads native code.
Minimal check (observed from source; runs in seconds): start the controller on host A, then from host B on the same network — no credential required, the shared library is loaded into the controller process:
curl -s -X POST http://A:3001/app -H 'content-type: application/json' \
-d '{"app":"<base64 of a .so exporting the jrtc app entry points>","app_name":"probe","runtime_us":1000,"deadline_us":0,"period_us":1000,"ioq_size":16,"app_path":"/tmp/probe","app_type":"binary","app_params":{},"device_mapping":{},"app_modules":[]}'
Expected: loopback by default, and remote management possible only after the integrator turns it on deliberately.
Suggested fixes:
- Default the REST bind to
127.0.0.1, with a configurable bind address for integrators who want remote management.
- Add optional shared-token authentication (e.g. a bearer token from an environment variable or the config file) enforced as axum middleware, so a remote bind never means anonymous app loading.
- Optionally verify app payloads against an integrator-configured hash allowlist before
dlopen, so only artifacts the integrator has blessed can be loaded.
2. unload_app indexes app_envs[app_id] with no bounds check
DELETE /app/{id} passes the raw i32 path segment to the C unload_app (src/controller/jrtc_int.c L396-400), which indexes the fixed-size global array struct jrtc_app_env* app_envs[MAX_NUM_JRTC_APPS] (declared at L95) with no range check — a negative or large id reads out of bounds. A one-line guard closes it:
if (app_id < 0 || app_id >= MAX_NUM_JRTC_APPS) return -1;
and the REST layer could map out-of-range ids to a 400 response.
Environment
jrt-controller @ f18345e0fb23eb5bebdfead10b1335678c9dcaf2 (main, 2026-09-15). Findings from source review; the curl one-liner above is the minimal runtime check.
Happy to send PRs for any of these if the approach sounds right.
Summary
The management REST API defaults to
0.0.0.0:3001with no authentication on any route, andPOST /appfeeds caller-supplied bytes straight into adlopeninside the controller process. For the documented single-host usage (jrtc-ctl againstlocalhost:3001) nothing is exposed; but the defaults an integrator inherits before wiring a management framework are bind-all-interfaces with anonymous app loading. Two asks: safest-by-default binding plus an optional auth token — and one small memory-safety fix found next to it.1. Unauthenticated management API on all interfaces, feeding an unsigned native-code load path
In
src/rest_api_lib/src/lib.rsthe router has no authentication or authorization middleware on any route, and the server binds every interface:load_apptakes the base64appbytes fromPOST /appand passes them to the Cload_appcallback (src/controller/jrtc_int.cL300-335), which writes them to a memfd and callsdlopen("/proc/self/fd/N", RTLD_LAZY)(_jrtc_load_app_from_memory,src/controller/jrtc_int.cL149-176). There is no signature, hash, or allowlist check anywhere on this path.Consequence: any host that can reach port 3001 can load arbitrary native code into the controller process — the process holding the jbpf shared-memory telemetry and control channels to the instrumented network functions. On a testbed where the controller and the RAN workloads share a bridge network, that is every peer on the bridge. The
app_type: "python"route reaches a similar outcome: REST-supplied paths are appended tosys.pathand REST-supplied modules are imported (src/pythonapp_loader/jrtc_pythonapp_loader.cL161-169, L357-367).The README positions the management plane as integrator territory — jrtc-ctl "can be optionally integrated with other management frameworks (e.g. nRT-RIC or SMO in O-RAN terminology)". That is exactly why the built-in defaults should be safe without such a framework: a deployment that has not integrated one inherits today's bind-everywhere, no-auth defaults, and startup prints
listening on 0.0.0.0:3001with no indication that the port loads native code.Minimal check (observed from source; runs in seconds): start the controller on host A, then from host B on the same network — no credential required, the shared library is loaded into the controller process:
Expected: loopback by default, and remote management possible only after the integrator turns it on deliberately.
Suggested fixes:
127.0.0.1, with a configurable bind address for integrators who want remote management.dlopen, so only artifacts the integrator has blessed can be loaded.2.
unload_appindexesapp_envs[app_id]with no bounds checkDELETE /app/{id}passes the rawi32path segment to the Cunload_app(src/controller/jrtc_int.cL396-400), which indexes the fixed-size global arraystruct jrtc_app_env* app_envs[MAX_NUM_JRTC_APPS](declared at L95) with no range check — a negative or large id reads out of bounds. A one-line guard closes it:and the REST layer could map out-of-range ids to a 400 response.
Environment
jrt-controller @
f18345e0fb23eb5bebdfead10b1335678c9dcaf2(main, 2026-09-15). Findings from source review; the curl one-liner above is the minimal runtime check.Happy to send PRs for any of these if the approach sounds right.