Skip to content

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.

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”.

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.

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.

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.

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 returning false instead.
  • Chain passes → counted Passed, and the handler runs under pcall with the post-validation arguments (validators that sanitize context.Args in place are honored, and nil holes survive positionally). If the handler crashes, the case lands in Report.HandlerErrors as {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.

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").ChloeKernelServer
local 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)
end

Every 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.

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 >= 0 but 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.Character and you passed a fabricated session → test-setup artifact, not a real finding; use a real session.
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.
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.