BigMoeOnEdge/SECURITY.md
Raffaele 03cb0aea7c
refactor: no hardcoding, no regex, the duplication removed and the server and app hardened (#262)
* docs: a security policy, the forms a contributor expects, and indexes that list every page

SECURITY.md says how to report a problem privately and what is in scope. A
bug-report form and a pull-request checklist join the benchmark form. The docs
index lists the framework, screens and design pages; the bench-data index lists
all eighteen sessions; the agent guide names session.cpp as the composition
root; and the public pages call the test phone by its class, not its model.

* refactor(params): RunConfig holds tunables only, and the table names every bound

--progress and --compute-trace-layers wrote RunConfig fields the parameter table had no row for.
Neither is a tunable of a run: the line protocol is the CLI's output mode and reaches the engine as
RunOutputs::render_text (run() takes its callback, sinks and that switch as one RunOutputs), and
the compute trace's granularity belongs to the trace (IComputeTraceSink::layers_only()).
BMOE_PROGRESS goes through the CLI's env-override table, --ppl-skip through the table's strict
integer parser (now public as parse_int_strict), the last literal bounds are named constants
validate() checks too, and the sink factories return std::unique_ptr.

* fix(server): a malformed request is a 400, an answer fetches nothing, a network client opens only the models folder

A field of the wrong type threw out of the JSON library as a 500; reads are now type-checked, a
body must be an object, and a JSON exception a handler still throws is a 400. Model output keeps
an image only when its bytes are inline, and every response carries a Content-Security-Policy with
nosniff and no-referrer. Sec-Fetch-Site same-site is refused on the API like cross-site.
--api-key-file keeps the key off the command line. A client whose peer address is not loopback
names models inside the models directory only, after canonicalisation; --models-only confines
local clients too. The default host, port, context and answer length are named in server.h.

* ci: a typed tag or a signing secret reaches the shell as data, never as script

inputs.tag and the release tag name were interpolated into run: lines, and the keystore passwords
into an unquoted heredoc. All of them now travel through env: and quoted expansions (printf '%s'
for the properties file), a tag that is not a plain ref name stops the run, and ci.yml declares
contents: read at the top, the APK job keeping its own write permission.

* fix(io): direct reads are aligned to what the file's filesystem states, not to 4096

Every reader cut its O_DIRECT windows at a literal 4 KiB and the storage probe
reported that literal as a measurement. The reader now asks the descriptor it
opened (pio::direct_alignment: statx STATX_DIOALIGN on Linux/Android where the
kernel and sandbox allow it, the filesystem block size otherwise, the volume
sector sizes on Windows, the block size on Apple) with one named fallback, and
destination buffers are aligned to that granule and never less than a page.

The refault price is read at the VM page, row-stream slabs are whole pages,
the readiness flags are padded to the widest cache line, the swap probe finds
any zram device, and the spin pause and the decide probe's file reads moved
into core/src/io so no OS branch is left in core/src/moe.

* test(gates): the RPC device listens on a port the kernel picks, not on 50599

Two gate runs on one machine (two build trees, two CI jobs on one runner) met
on the fixed port: the second failed to bind, or registered the first's server.

* refactor: one probe-and-plan sequence in core, and every tensor name read in one place

bmoe-cli --auto and the server's /api/plan each carried the planner's probe
calls in the same order, the cache-floor arithmetic and the device-probe
timeout. Both now call probe_and_plan (bmoe/auto_plan.h) with what differs as
options, and the isolated probe's child is probe_device_child; neither
front-end includes llama.h to plan.

plan_run is a list of named stages over one planning state and allocate a
list of named steps; every plan the planner tests make is identical before and
after. Rationale that tells a person what to type spells the flag through the
parameter table (flag_spelling). The smallest suggested context and the
fitter's device margin and minimum context are named constants.

Tensor, graph-node and fitter override-pattern names are parsed by
core/src/moe/tensor_names, replacing the sscanf, strncmp and std::regex
copies in the router hook, decide probe, model probe, prefill path, session
and placement probe. Unit-tested.

* refactor(server,ui): the UIs read the facts the server reports, not engine parameters

The engine reports the wall-additive flash term already chosen for the run's
mode (flash_wait_ms per token, flash_wait_s_tok in the summary), and the loaded
model in /api/info carries the threads a CPU figure is read against
(compute_threads, busy_threads). The web UI's live line, metrics card and
streaming badge read those facts and name no engine parameter.

--scan-dir PATH lists models outside the models directory, read in place;
/api/models adds scan_dirs and each model's dir and files. Split sets are
recognised by an explicit, tested parser (server/model_files.h) instead of
std::regex. The Hexagon backend's environment moves into server/platform.cpp.

The web UI checks the shape of every answer and event it reads, asks for the
config again on reconnection, and keeps a failed message in the transcript with
Send again, as docs/screens.md specifies; the chat store's failure path is
tested against a stubbed server.

* refactor(android): the app reads the server's facts and labels, and keeps no model logic

The live line and the metrics card read the flash term and the threads the
server reports instead of the threads, io-threads and overlap settings with
invented defaults. A recorded run's settings are labelled, typed and explained
by the parameter table; the hand-kept preamble labels are gone, and a run's
identity names every setting that changes the reply and is off its default.

GgufHeader.kt and the shard-name regexes are deleted: the service passes the
phone's other model folders as --scan-dir, the Models screen lists what the
server lists, and a delete removes the files the server named. A summary's rate
is read by field name, a chat error by its code, a closed port by
ConnectException. A failed message can be sent again, and a failed decision
leaves no question behind.

* fix(android): the engine always takes a key, the app proves it is its own, and stops off the main thread

Loopback on a phone is reachable by every installed app, and the server ran
there without a key unless shared: any app could read the conversations off
/api/events. The server now always starts with a key, random per launch or the
shown one when shared, handed over in a 0600 file in the app's private storage
(--api-key-file), and every request carries it. Before trusting the server on
the port the app checks it refuses a random key and accepts its own; a port
held by another program is reported and sent nothing.

Starts and stops run one at a time on a background thread, a restart starts
the next server only once the previous one has ended, and a stopped server's
late exit no longer overwrites the state of its replacement. Every call has a
read timeout fitted to it, the event stream a watchdog of two missed pings, a
cancelled coroutine is not read as a failed call, and the port is one constant.

* refactor(session): open and generate read as their steps, and diagnostics go through a log port

Session::open() (~690 lines) and Session::generate() (~770) become short
sequences of named steps on the Impl: an Opening context for the load
(load_model ... warn_about_load) and a Turn for one generation
(render_prompt, tokenize_prompt, reuse_kv_prefix, prefill, decode with
draft / verify_decode / accept / emit, trim_speculative_kv, summarize,
finish). Same order of operations, error kinds, messages and metrics;
the byte-identity gates pass. RouterHook::on_eval dispatches to
ctrace_observe, capture_sources, arena_eval, serve_rows and on_topk,
each reached only when its feature is on.

bmoe/log.h: set_log_sink / set_log_level and log_printf. Core's 70
diagnostic fprintf(stderr) calls go through it; the default sink writes
the same 'bmoe: <line>' bytes. Protocol lines never pass through it.

* test(server): the engine host's state machine, driven with a scripted session

EngineHost opens sessions through a SessionOpener over the narrow
HostedSession view of a Session (server/hosted_session.h), the real
Session by default. tests/engine_host_test.cpp drives loads, turns while
loading, cancel and unload mid-turn, a failed decode closing the session
and a recoverable kind keeping it, and decide's fatal rule.

The host also installs a log sink: the same stderr line, plus a 'log'
event (level, message) on /api/events.

* ci: the gates under ASan and UBSan, on macOS too, and the web UI type-checked

A Linux job runs ctest with -fsanitize=address,undefined (RelWithDebInfo,
llama.cpp instrumented too); upstream findings this project does not
patch are listed in tests/sanitizers/ubsan.supp. The macOS job now builds
the tests and runs the gates. The ui job adds npm run check and the
build. Windows stays compile-only, with the reason in the workflow.

* docs(architecture): the layer map names every header and source the engine has

The map left out the session, chat, decide, n-gram draft, trace and version
headers, the stall union and the chat-message adapter. The roadmap names the
I/O bench by its source file.

* docs(changelog): this work is unreleased, not part of 0.30.0

0.30.0 was cut while these changes were in review, so their entries moved from
its section to a new [Unreleased] one, grouped as added, changed and fixed.

* fix(prefill): the dense layer slots are one table, indexed as they were sized

Choosing between two member arrays with a conditional and indexing the result
was read as the wrong parity for the matrix slot by GCC 13 under ASan+UBSan, so
nemotron_h_moe's recurrent layer landed in the smaller slot and ggml aborted
(gate G17, found by the new sanitizer job). One [data][parity] table, indexed
the way init_dense sizes it.

* ci: llama.cpp's process-lifetime RPC objects are listed for LeakSanitizer

The RPC registry, its device and buffer type, and the loopback rpc-server the
prefill-device gate starts live as long as the process upstream; they are named
one by one in tests/sanitizers/lsan.supp, as the UBSan findings are.

* fix: the probed read granule keeps its floor, the flag padding follows the target, and the slot rewrite is not called a fix

Three findings of the review, each a change this branch made without the measurement or the
case that would justify it, taken back to what is known:

- The direct-read granule statx reports is the legality floor, and on ext4 over a 512-byte-sector
  NVMe it says 512. Reading at 512 where 4096 was read before is a throughput change on the SSDs
  this project has measured punishing small reads, and it was not measured, so the granule never
  goes under direct_align_fallback. The kernel's answer still raises it (16 KiB f2fs).
- The padding between the streamer's ready flags went from 64 to 128 bytes everywhere because
  Apple's cores have 128-byte lines. It sizes the hottest poll in the engine, one flag per expert
  and projection, so it now follows the target: 128 on Apple arm64, 64 elsewhere.
- The dense-slot rewrite in the prefill arena selects the same buffer as the conditional it
  replaced and cannot have fixed the G17 abort seen under ASan+UBSan. The comment and the
  changelog say so, and record that fault as open rather than as a compiler bug nobody reproduced.

Also: the Android picker now lists dense ggufs too, labelled, which the changelog did not say;
the agent guide notes the server's two env writes; the gate's port helper describes the window it
actually has.

* fix(server): a turn sent the moment a load is announced waits for the load

load() reads "loading" before its thread has taken the generation lock,
and a request in that gap took the lock first, found no session and
answered 503 for a model seconds from ready. The engine host test caught
it on Windows ("a turn waits for a load that will fail", intermittent).

A turn now takes the lock through take_turn(): while the state reads
loading it gives the lock back and waits on the state, then takes it once
the load has ended either way. The state change notifies through
publish_state(), which every transition already calls. The test flushes
each check, so a check that hangs the next one is still on record.
2026-10-18 10:49:39 +02:00

1.2 KiB

Security policy

Reporting a vulnerability

Please report security problems privately, through GitHub's private vulnerability reporting, not in a public issue. Say what you found, how to reproduce it, and which build or release it affects. You will get an answer within a week; a confirmed issue is fixed in a release and credited to you in the changelog unless you ask otherwise.

What is in scope

  • bmoe-server and its HTTP API: authentication, the origin and host checks, request parsing, anything that lets a client read or write what it should not (docs/server-api.md states what the server protects and what it does not).
  • The Android app, as a client of the server it starts.
  • Reading a model file: a GGUF that makes the engine read or write out of bounds.
  • The release pipeline: anything that would let a release asset be built from something other than the tagged source.

What docs/server-api.md lists under "What is not" protected is documented behaviour, not a vulnerability; a way around one of the protections it lists is.

Supported versions

Only the latest release gets fixes.