Fuzz & Security Audit
Two tools verify the security surface, and they split cleanly: securityAudit() reads configuration, Fuzz.run() exercises handling. The audit sweeps every registered channel and gate for structural mistakes — unguarded handlers, fail-open gates, escape hatches, defaulted rate limits. The fuzzer then drives hostile payloads through the real validator-and-handler pipeline to prove the configuration holds under fire. Both are one call, both return machine-checkable results, and both gate the framework’s Studio boot by default.
Security audit
Section titled “Security audit”What one call sweeps
Section titled “What one call sweeps”Kernel:securityAudit() audits every intent and request channel registered on the NetDriver, plus every hook point on the gate namespaces. It checks the four assumptions the security model makes:
| Kind | Severity | Fires when | Why it matters |
|---|---|---|---|
UnguardedChannel |
High | A channel has a Handler, no validator on its Intent.*/Request.* topic, and is not Open |
The channel is fail-closed dead weight: every payload rejects and the driver warns once at runtime. Either the validator was forgotten or the handler should not exist — both are mistakes. |
FailOpenGate |
High | A hook point named Intent.* or Request.* is defined FailOpen |
On a fail-open point, a crashing validator PASSES the payload. Gate namespaces must stay fail-closed; this is the finding that catches a copy-pasted definePoint flipping the default. |
OpenChannel |
Medium | A channel is marked Open = true and has a Handler |
Open is the deliberate escape hatch: any client payload reaches the handler unvalidated. Sometimes correct (ReadyUp-style no-stakes intents) — but every one must be confirmed safe against hostile input. |
DefaultRateLimit |
Info | A channel has a Handler and no explicit RateLimit was passed |
The channel rides the default 30/s rate limit. Not a hole — a prompt: set the limit to what the gameplay actually needs (a fire intent needs ~8/s, not 30). |
The exact Detail strings ship in the findings, e.g. UnguardedChannel: “has a handler but no validator and is not Open — every payload rejects; add a validator or audit Open”.
Finding shape and ordering
Section titled “Finding shape and ordering”Every finding is {Severity, Kind, Name, Detail}. Severity is "High", "Medium", or "Info". Name identifies the subject: intent Grant, request Buy, hook Intent.Buy. Findings are sorted most severe first (High, Medium, Info), alphabetically by Name within a tier — so Findings[1].Severity ~= "High" is a valid one-line ship gate.
Fail-open points off the gate namespaces are never flagged: observation points like Net.RateLimited or AntiExploit.MovementViolation are fail-open on purpose (a crashing listener must not silence the others), and the audit knows the difference.
Running it
Section titled “Running it”Kernel:securityAudit()-- [High] UnguardedChannel — intent Grant: has a handler but no validator ...-- [High] FailOpenGate — hook Intent.Buy: guards a channel but is FailOpen ...-- [Medium] OpenChannel — intent ReadyUp: any client payload reaches the handler ...-- [Info] DefaultRateLimit — intent Chat: rides the default rate limit (30/s) ...Without options it prints the summary — security audit: clean or one warn block listing every finding. Pass { Silent = true } to skip the print and assert on the return value instead:
local Findings = Kernel:securityAudit({ Silent = true })assert(#Findings == 0, "ship blocked: security audit found issues")Run it after start(), not after boot(): the Bootstrap registers services and channels between the two, and the audit reads configuration — auditing before registration completes audits nothing.
If the game never built a NetDriver (kernel:net() was never called), securityAudit still runs: it skips the channel sweeps and audits hook gate points only, so the FailOpenGate check holds even for games without networking. The narrow variant Net:auditValidators() remains available when you only want the unguarded-channel list as {Name, Kind} pairs.
Fuzzing
Section titled “Fuzzing”The audit proves the doors are configured; the fuzzer rattles them. Fuzz.run builds hostile argument sets for every registered intent and request and drives each one through the server’s real pipeline: fire the channel’s fail-closed hook chain, then call the handler when the chain passes. No mocks — the same validators and handlers live traffic hits.
Hostile payload generation
Section titled “Hostile payload generation”Each schema entry maps to a pool by type-name prefix, so every sized wire variant (NumberU8, StringLong, Vector3F24, Boolean8) draws from the right pool:
| Schema prefix | Pool contents |
|---|---|
Number* |
0, 1, -1, 255, 256, 65535, 65536, 2^31, -2^31, 2^53, 0.5, -0.5, NaN, math.huge, -math.huge |
String* |
"", "a", a 10,000-char string, "-1", "0", "%s%d%n" (format specifiers), "\0\1\255" (control bytes), "nil", "true", 100 repeated UTF-8 BOMs |
Vector3* |
Vector3.zero, Vector3.one, (1e6, -1e6, 1e6), a NaN component, ±math.huge components, (-0.001, 0.001, 0) |
Boolean* |
true, false |
Anything else (incl. Any) |
nil, "", a 10KB string, NaN, math.huge, -1, {}, a deeply nested table, a mixed array/hash table, true, a NaN-component vector |
Note what those pools contain: values a real client cannot send. The wire’s typed serialization bounds live traffic — a NumberU8 slot can never carry NaN off the wire. The fuzzer sends past those bounds on purpose. That is defense in depth: validators should hold even if the transport layer’s guarantees ever change, and Any-typed slots never had transport guarantees to begin with.
Each case draws one pool value per schema slot from a seeded Random, CasesPerChannel times per channel — runs are deterministic per Seed, so a failing case reproduces exactly.
The pipeline, and what counts as a failure
Section titled “The pipeline, and what counts as a failure”Per case, the fuzzer builds the same context live traffic gets — {Player, Session, Args} — and fires the channel’s real Intent.*/Request.* topic:
- Chain rejects, or the channel has no handler → counted
Rejected. This is the healthy outcome for hostile input. A validator that throws also counts as a reject — fail-closed holds under fuzzing — but surfaces as a kernel warning, because a throwing validator should be returningfalseinstead. - Chain passes → counted
Passed, and the handler runs underpcallwith the post-validation arguments (validators that sanitizecontext.Argsin place are honored, andnilholes survive positionally). If the handler crashes, the case lands inReport.HandlerErrorsas{Channel, Kind, Args, Error}with the original payload.
Cases == Passed + Rejected always; HandlerErrors is the number that must be zero. A payload that passed validation and crashed the handler is a bug in one of the two — either the validator promises a domain it doesn’t enforce, or the handler assumes more than the validator promises. Prefer tightening the validator: handlers should only ever see the domain the validator guarantees.
Running it
Section titled “Running it”Fuzz.run needs a session, because handlers read session.Player, profiles, and character state. Pass a real session — a fabricated one makes handlers report errors no real client could cause:
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Fuzz = require(Root.Fuzz)
Kernel:onSession(function(session) local Report = Fuzz.run(Kernel, { Session = session, CasesPerChannel = 64 }) assert(#Report.HandlerErrors == 0, "fuzz found handler crashes")end)The framework’s stock Main.server.luau already wires both tools as Studio boot gates: after start(), the audit runs and asserts no High-severity finding survives; on the first session of a play test, the fuzzer sweeps every channel and errors loudly if any handler crashed:
if RunService:IsStudio() then task.defer(function() for _, Finding in Booted:securityAudit({ Silent = true }) do assert(Finding.Severity ~= "High", `security audit: {Finding.Name} — {Finding.Detail}`) end end) local Fuzzed = false Booted:onSession(function(session) if Fuzzed then return end Fuzzed = true task.defer(function() local Report = Fuzz.run(Booted, { Session = session, Silent = true }) if #Report.HandlerErrors > 0 then for _, Failure in Report.HandlerErrors do warn(`[Fuzz] {Failure.Kind} "{Failure.Channel}" handler error: {Failure.Error}`) end error(`[Fuzz] {#Report.HandlerErrors} handler error(s) — see warnings above`) end end) end)endEvery Studio play test is therefore also a security regression run: register a new channel with a loose validator and the boot fails before you finish walking to the test dummy. Unless Silent = true, each run also prints a summary line — [Fuzz] 12 channels, 384 cases: 371 rejected, 13 passed, 0 handler error(s) — and warns once per handler error.
Interpreting a failure
Section titled “Interpreting a failure”A HandlerErrors entry gives you the channel, the kind ("Intent" or "Request"), the exact argument list, and the error string. Reproduce it by re-running with the same Seed and Channels = { "TheChannel" }, then decide which side lied:
- Handler asserts
value >= 0but the validator never checks sign → validator bug, add the bound. - Validator checks type but handler indexes a nested field → handler bug (or the schema should not be
Any). - Handler reads
session.Player.Characterand you passed a fabricated session → test-setup artifact, not a real finding; use a real session.
Kernel:securityAudit(options?)
Section titled “Kernel:securityAudit(options?)”| Member | Description |
|---|---|
kernel:securityAudit({Silent: boolean?}?) → {AuditFinding} |
One sweep over channels and gate hooks. Prints a summary unless Silent. Returns findings sorted most severe first. |
AuditFinding |
{Severity: "High"|"Medium"|"Info", Kind: string, Name: string, Detail: string} |
net:auditValidators() → {{Name, Kind}} |
Narrow sweep: unguarded channels only. |
Fuzz.run(kernel, options)
Section titled “Fuzz.run(kernel, options)”| Option | Default | Description |
|---|---|---|
Session |
(required) | The session handlers run against. Use a real one. |
Channels |
all | Allowlist of channel names to fuzz. |
CasesPerChannel |
32 |
Argument sets per channel. |
Seed |
1 |
RNG seed; runs are deterministic per seed. |
IncludeRequests |
true |
Set false to fuzz intents only. |
Driver |
kernel:net() |
Registry override {Intents, Requests} — used by specs to fuzz fabricated channels. |
Silent |
false |
Suppress the summary print and per-error warns. |
| Report field | Description |
|---|---|
Channels |
Channels fuzzed. |
Cases |
Total cases (Channels × CasesPerChannel, request channels included). Always equals Passed + Rejected. |
Passed |
Cases the validator chain let through to a handler. |
Rejected |
Cases rejected by the chain (validator false, validator throw, or no handler). |
HandlerErrors |
{{Channel, Kind, Args, Error}} — the list that must be empty. |