Security, correctness and dead code fixes - #13
Merged
Merged
Conversation
`Storex.Store.__mutation__/6` rescued every `FunctionClauseError` raised
under the store's `mutation/5` call and reported it as "No mutation
matching ...". That message is only correct when the store itself has no
clause for the incoming name and data. An error raised deeper inside a
mutation that did match was reported the same way, with the real
stacktrace discarded.
The two cases are distinguishable: a `FunctionClauseError` carries the
module, function and arity that failed to match. Only
`{store, :mutation, 5}` means "no such mutation"; anything else is
reraised with its original stacktrace.
This changes observable behaviour. A crash inside a matching mutation
used to reach the client as an error message; it now propagates, stops
the store process and takes the socket with it.
`Storex.Supervisor.name/2` built the store process name with
`String.to_atom("#{session}_#{store}")`. Session ids come from
`Nanoid.generate/0` and are unique per connection, so every join created
a new atom, and atoms are never garbage collected. One reconnect is one
new atom, which makes this reachable by ordinary traffic rather than
abuse: a long-running node with enough connection churn exhausts the
atom table and the VM dies.
Nothing ever looked a store up by that name. `Storex.Registry` maps
`{store, session}` to the pid and is what every operation actually uses.
The name was only load-bearing in that registering it twice fails, which
keeps a second process for the same `{session, store}` from starting.
A `Registry` keyed by the `{session, store}` tuple keeps that guarantee
(`start_link` still returns `{:error, {:already_started, pid}}`) and
creates no atoms.
`Storex.Socket.message_handle/2` looked the store up by `message.session`
— the session id the client put in the frame — and never compared it
against `state.session`, the id the handler generated for this
connection. Any client could therefore mutate any other session's store
by naming its session id, and received the resulting diff (and any
`{:reply, message, state}` payload) back on its own socket.
The `join` clause a few lines above already used `state.session`. This
brings the mutation clause in line with it. The field stays in the frame
and is still echoed in the response, but it no longer resolves anything.
Deliberate cross-session mutation is what `Storex.mutate/3` and
`Storex.mutate/4` are for.
Fixing that alone would leave a reachable crash: a mutation for a store
the session has not joined makes `Storex.Registry.get_store_pid/2`
return `:undefined`, which `Storex.Supervisor.mutate_store/4` piped
straight into `GenServer.call/2`, exiting the connection process. It was
reachable before this change by naming a session that does not exist, so
it is fixed here too — the lookup miss now returns an error the client
receives as a normal error frame.
`get_store_state/2` has the same shape but is left alone: its only
caller runs inside the success branch of the join `with`, right after
`add_store` registered the process, so the miss is unreachable there.
`Storex.Handler.Cowboy` decoded `:binary` frames with `:erlang.binary_to_term/1`. Two problems in one line. It creates atoms out of bytes the client controls. A hand-built 25-byte external term format frame is enough to add one, so a single open socket can grow the atom table as fast as it can write, with no connection churn needed. It is also the only unauthenticated `binary_to_term` in the library. It also bypassed `Storex.Message.cast/1`, the allowlist every other entry point runs its payload through, and handed a raw term straight to `Storex.Socket.message_handle/2`. The `rescue ArgumentError` only covered a malformed encoding; a well-formed term of an unexpected shape raised `FunctionClauseError` and closed the connection with a 1011. Nothing uses binary frames. The client only ever sends `JSON.stringify` output, no test sends one (the `send_binary_frame/3` helper had no callers until now), and the README never documented the path. Keeping it would mean maintaining a second set of `cast/1` clauses for atom-keyed maps, for no caller. So the frame is refused with a 1003 instead. `Storex.Handler.Plug` had no `:binary` clause at all, which made any binary frame raise `FunctionClauseError` and kill the connection with a 1011 on the default transport. It now refuses the frame the same way.
A store name arrives as a string from the client, so resolving it to a module needs three checks together: `Module.safe_concat/1` so no new atom is created, `Code.ensure_compiled/1` so the name maps to a real module, and a check that the module declares the `Storex.Store` behaviour. `Storex.Socket` did all three. `Storex.HTTP` kept its own copy that had drifted down to `Module.safe_concat/1` alone, and then called `apply(module, :init, ["SSR", params])` on whatever came back. So `GET /storex?store=Any.Module¶ms=%7B%7D` invoked `init/2` on any loaded module whose name resolved and serialised the return value to the caller, and a module whose `init/2` returned an unexpected shape raised, which made the endpoint a probe for which modules exist. The defect is the duplication, not the missing lines, so both callers now share `Storex.Store.resolve/1` and the copies are gone. `Storex.HTTP.get_state/2` had the same problem on a smaller scale: its own `apply/3` plus a three clause `result/1` where `Storex.Store.__init__/3` already exists. It now uses the shared dispatcher, so SSR accepts the same return values as the WebSocket path and reports the same error for anything else, instead of a bare `FunctionClauseError`. The SSR error message interpolated the store name with `inspect/1`, rendering it double quoted. It now matches the WebSocket wording.
Three unrelated pieces of code that were either unreachable or wrong.
`Storex.Message.cast/1` accepted an `error` frame, but
`Storex.Socket.message_handle/2` has no clause for one, so a client
could kill its connection process with a well-formed frame. The
direction is server to client, and those frames are built as plain maps
in `Storex.Socket` rather than through the struct, so nothing produced
or consumed the shape. It is out of the allowlist, which sends the frame
down the same path as any other unknown type: closed with 1007.
`Storex.Registry.session_pid/1` had no callers anywhere, and could not
have worked if it did: it matched `{:_, session, :_, :"$1"}`, a 4-tuple,
against a table of 5-tuples, with `session` in the `store_pid`
position. Its `:DOWN` handler also replaced the `%{}` state with the
atom `:ok`, which is harmless only for as long as nothing reads it.
`Storex.Handler.Cowboy.websocket_init/3` is the cowboy 1.x callback
signature. Cowboy 2.x calls `websocket_init/1`, and `init/2` already
returns the real state, so it never ran.
Testing the first of those turned up a fourth: the two handlers closed a
malformed payload differently. `Storex.Handler.Plug` passed the reason
as the process exit reason rather than the close payload, so the client
saw a bare 1007 while cowboy sent 1007 plus the reason, and the
connection process exited abnormally every time. It now uses the same
shape as the rest of its close responses.
`Storex.Supervisor.get_store_state/2` read a store's state with
`:sys.get_state/1` and then reached into the result with
`Map.get(:state)`. That is a debug function, it runs on the join path in
production, it uses the `:sys` timeout rather than the caller's, and it
couples the supervisor to the internal state shape of the `Server`
module generated in `Storex.Store` — a module in a different file that
cannot change its representation without breaking this caller.
The generated `Server` now answers `handle_call(:get_state, ...)` for
itself, and the supervisor calls that.
The return type becomes `{:ok, state} | {:error, reason}`, matching
`mutate_store/4`. Besides being one contract instead of two in the same
module, it closes the race left open when `mutate_store/4` was guarded:
the store process can be gone between `add_store/4` returning and the
join reading the state, since it is `restart: :transient`. That used to
be `:sys.get_state(:undefined)`, which exits and takes the connection
with it. The join now answers with an error frame.
The registry table is created `:protected`, which already lets any
process read it, yet every accessor went through `GenServer.call` to a
single process that then ran the `:ets.match` itself. Every mutation
performs at least one lookup, so that process was a global
serialisation point for the whole library.
Reads now run in the caller. Writes and the `:DOWN` cleanup stay in the
process, which still owns the table.
Measured at 500 lookups per reader, comparing the round-trip against
reading directly:
readers GenServer direct
1 1.7ms 0.6ms
4 6.7ms 1.8ms
16 24.8ms 10.5ms
64 98.3ms 16.2ms
256 335.8ms 56.2ms
The GenServer path grows linearly with load while the direct path does
not, which is the serialisation showing up rather than `:ets.match`
being slow.
Also removes the unreachable `{:error, _}` branch in
`Storex.PG.broadcast/1`. `:pg.get_members/2` always returns a list; the
error tuple was `:pg2`'s contract and `:pg2` went away in this release.
That branch was the reason `broadcast/1` used a comprehension, and
`send/2` returns the message it sent, so `Storex.mutate/3` and
`Storex.mutate/4` were handing callers the internal broadcast envelope
once per node:
Storex.mutate("Some.Store", "reload", ["x"])
#=> [broadcast: {:mutate, "Some.Store", "reload", ["x"]}]
They return `:ok` now.
`Storex.start/2` carried `import Supervisor.Spec, warn: false`. `Supervisor.Spec` has been deprecated since Elixir 1.5 and nothing in the function used it; the `warn: false` is what kept that quiet. `config/config.exs` still set `registry: Storex.Registry.ETS`. That module was removed in 0.4.0 and nothing reads the key. `mix format --check-formatted` failed on the `@callback terminate/3` line in `lib/storex/store.ex`, and had for as long as the file has looked like that. With it formatted the check passes on the whole project, so CI can run it. The check is a separate job on one pinned Elixir rather than a step in the test matrix. The formatter's output can change between releases, and a step in the matrix would fail all thirteen rows the first time it does. `.formatter.exs` declares no `import_deps`, so the job needs no dependencies.
Coverage found four gaps, and reading the tests found three more that coverage cannot see because the code runs during teardown without anything asserting on it. - `terminate/3` had never been executed. No fixture implemented it, so the whole optional-callback path was untested. There is a fixture for it now, and tests that it runs on `remove_store/2` with the state as of the last mutation. - The registry drops a row when the store process dies. That ran during teardown but nothing asserted it, which matters because the `:DOWN` handler is one of the match patterns that has to stay in step with the shape of the table. - Closing a connection stops the session's stores. Same situation. - Ping and pong. The client sends one every 30 seconds, making it the most frequently handled frame in production and the only one with no test. - `Storex.mutate/3` reaching every session of a store. The existing tests used a single session, so the fan-out — the entire point of the function — was never exercised. Also covers a session on a different store not being reached. - Joining a store that is already joined in the session returns the existing key and starts nothing. - `Storex.Diff` comparing a struct against a plain map, in both directions. The existing struct test compared two structs. Writing the first of those turned up a real defect. `Storex.Store.__terminate__/4` gated the callback on `function_exported?/3`, which answers `false` for a module that is not loaded. Under the normal flow the store is loaded by the time it terminates, because `init/2` was applied on it, so this never showed up — but whether a documented callback runs should not depend on what the code server happens to be holding. It checks `Code.ensure_loaded?/1` first now. Every test here was confirmed to fail against the unfixed code: seven deliberate breakages produce twelve red tests, and none on the code as it stands.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #10 and #11, working through the items that were deliberately left out of those two PRs.
Every item below was verified before being fixed — measured, or reproduced with a throwaway test — and each fix has a regression test that was confirmed to fail against the old code.
Security
Any client could mutate any other session's store.
Storex.Socket.message_handle/2resolved the store withmessage.session, the session id the client put in the frame, and never compared it againststate.session. Reproduced: a socket owning"attacker-session"sent a mutation naming"victim-session"and the victim's counter went 0 → 1, with the diff pushed back to the attacker. Thejoinclause two lines above already usedstate.session.Untrusted deserialisation on cowboy binary frames.
:erlang.binary_to_term/1with no[:safe], on raw socket bytes. A hand-built 25-byteATOM_UTF8_EXTframe adds an atom, so one open socket can grow the atom table as fast as it can write. It also skipped theStorex.Message.cast/1allowlist entirely. Nothing used the path — the client only ever sendsJSON.stringifyoutput, no test sent a binary frame, the README never documented it — so binary frames are now refused with1003.While testing that:
Storex.Handler.Plughad no:binaryclause at all, so any binary frame raisedFunctionClauseErrorand closed the connection with1011, on the transport the README recommends.The SSR/HTTP path skipped two of the three module-resolution guards.
Storex.HTTPkept its own copy of the store-name resolution with onlyModule.safe_concat/1— noCode.ensure_compiled/1, no behaviour check — then calledinit/2on whatever came back.GET /storex?store=Any.Module¶ms=%7B%7Dtherefore reachedinit/2on any loaded module whose name resolved. The defect is the duplication rather than the missing lines, so both transports now shareStorex.Store.resolve/1.Resource exhaustion
One permanent atom per session-store pair. Store processes were named
:"#{session}_#{store}", and session ids are unique per connection, so one reconnect was one new atom. They now register through aRegistrykeyed by the{session, store}tuple, which keeps the duplicate-start guard the name was accidentally providing. Regression test asserts a zero atom delta over 200 joins; against the old code it measures +291.Crashes reachable from a client
errorframe passedStorex.Message.cast/1and then had no clause inmessage_handle/2. Those frames only travel server to client, so the shape is out of the allowlist.:undefinedstraight intoGenServer.call/2.Performance
Storex.Registryreads now run in the calling process against the:protectedtable instead of aGenServer.call. Every mutation performs at least one lookup, so that process was a global serialisation point. Measured at 500 lookups per reader:The round-trip grows linearly with load and the direct path does not, which is the serialisation showing rather than
:ets.matchbeing slow.Correctness and dead code
FunctionClauseErrorraised inside a matching mutation was reported to the client asNo mutation matching ...with the real stacktrace discarded. Only the store's ownmutation/5failing to match produces that error now.:sys.get_state/1, a debug function, on a production path, reaching into the generatedServer's internal state shape from another file. The process answers for itself now.Storex.Handler.Plugpassed the reason as the process exit reason rather than the close payload, so the client saw a bare1007and the connection exited abnormally every time.Storex.Registry.session_pid/1(no callers, and its:ets.match/2pattern was a 4-tuple against 5-tuple records, so it never matched),Storex.Handler.Cowboy.websocket_init/3(cowboy 1.x signature), and the unreachable{:error, _}branch inStorex.PG.broadcast/1.import Supervisor.Spec, warn: falsefromStorex.start/2(deprecated since Elixir 1.5, unused, and thewarn: falsewas hiding it) andregistry: Storex.Registry.ETSfromconfig/config.exs(that module went away in 0.4.0 and nothing read the key).Formatting
mix format --check-formattedfailed on the@callback terminate/3line inlib/storex/store.ex, and had for as long as the file has looked like that. It is formatted now, so the check passes on the whole project and CI runs it.The check is a separate job on one pinned Elixir rather than a step in the test matrix — the formatter's output can change between releases, and a step in the matrix would fail all thirteen rows the first time it does.
Breaking changes
Marked
**[BREAKING]**in the changelog:FunctionClauseErrorfrom inside a matching mutation now propagates instead of coming back as an error message.Storex.Handler.Cowboyno longer accepts binary frames.Storex.mutate/3andStorex.mutate/4return:ok. They used to return the internal broadcast envelope once per node —[broadcast: {:mutate, "Store", "reload", []}].Test coverage
A coverage run turned up behaviour with no tests at all, and reading the suite turned up more that coverage cannot see — code that runs during teardown with nothing asserting on it.
terminate/3had never executed. No fixture implemented it, so the whole optional-callback path was untested.:DOWNhandler is one of the match patterns that has to stay in step with the shape of the table, so a silent regression there leaks processes.Storex.mutate/3reaching every session of a store. The existing tests used one session, so the fan-out — the point of the function — was never exercised.Storex.Diffcomparing a struct against a plain map in both directions.Writing the first of those found a defect:
Storex.Store.__terminate__/4gated the callback onfunction_exported?/3, which answersfalsefor a module that is not loaded. Under the normal flow the store is loaded by then, so it never showed up, but whether a documented callback runs should not depend on what the code server happens to hold. It checksCode.ensure_loaded?/1first now.Coverage after:
Storex.DiffandStorex.Socketat 100% (from 89.7% and 90.5%),Storex.Supervisor95.2%,Storex.Store89.3%. What is left uncovered is five defensive fallbacks — catch-allhandle_infoclauses, theterminateclause for a state with no session, and the malformed-paramsbranch inStorex.HTTP.Testing
115 non-browser tests pass, up from 54 on
master.mix compile --warnings-as-errorsandmix format --check-formattedare both clean.The browser tests could not run locally — chromedriver is not installed in the environment this was written in, so all 21 fail there for that reason alone, on
mastertoo. Confirming them on CI is why this is a PR rather than a direct push. They pass:119 tests, 0 failureson every matrix row.Not included
The frontend lives in a sibling repo and is untouched here. The most valuable finding there:
patch()mutates state in place and returns the same object reference, so React, Vue and Svelte identity checks see no change and do not re-render. Also a per-commit()leak in the pending-request map, promises that never settle when the socket drops, and a reconnect loop with no backoff.