Without a keychain, account-secrets.json was sealed under SHA-256 of a
constant and /etc/machine-id, which every local user can read; on a
system with none of the machine-id files the key was a constant built
into every launcher. Only the file's 0600 mode protected the Microsoft
refresh token inside, so a copy restored with wider permissions, or a
shared home, gave it away.
Format 2 (HZSECv2) adds a random 32-byte install secret to the key,
kept beside the file in account-secrets.key (0600, made at the first
write through a temporary file and a rename, under the file's lock).
The key is never the bare constant any more, and a v2 file whose
secret is missing or belongs to another install is refused like any
unreadable file, never written over.
A file sealed by an older launcher (HZSECv1) is still read with the old
key and sealed again under the new one at once.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
cargo audit flagged two crates in the locks:
- tokio 1.53.2 (RUSTSEC-2026-0386), reached through quinn in
horizon-mod-deps and in the launcher's lock. Patched in 1.53.3, a
patch release, so both locks move there.
- lru 0.16.4 (RUSTSEC-2026-0253), reached only through mysql 27 in
horizon-mod-deps. No 0.16 release carries the fix; mysql 28 depends
on lru 0.18, which does, so the crate mods may use moves to mysql 28
(mysql_common 0.37, pem 4, base64 0.23 and arc-swap come with it).
`cargo check -p horizon-mod-deps --features mysql` builds, and cargo
audit no longer reports either advisory. A native mod that asks for
`mysql = "27"` now has to ask for 28, since the lock is the allow list;
docs/native-mods.md names the new version.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
neutralise breaks @mentions, #123 and github.com/ issue links in what
people write in the website's report form, so an anonymous report can't
put backlinks on the repository's issues. GitHub also links GH-123
(either case) to issue 123, which got through.
- At the start of a word, GH- followed by a digit gets a zero-width
space after GH, the way @ and # already do: the text reads the same
and links nothing. Other words (high-5, GH-pages, laugh-1) are left
alone.
- Test: GH-12, gh-7 and (Gh-8) are neutralised; look-alikes aren't.
Found in yahya-nusseibeh's review of Horizon (L2, 7 Nov).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Two ways one client could lock every user out of the Play with Friends
relay, with no account:
(a) The only connection cap was global (4,096), counted before anything
is read, and each connection may stay silent for the 10 s first-line
wait: about 410 idle connections a second from one address kept the
relay full, so hosts couldn't register and guests couldn't join.
(b) Every join, known code or not, is refused while the global
unknown-code budget (120 a minute) is empty. Each /64 may try 5
unknown codes per 10 minutes, so ~240 /64s keep it empty, and one
residential /56 has 256 of them.
- Limiter::open_connection / close_connection: connections open at
once per address key (IPv6 /64; a /48 four times as many), 64 by
default (RELAY_MAX_OPEN_PER_ADDRESS; a host keeps one connection per
guest, hence the margin). Counted before anything is read, or right
after the PROXY line behind the PROXY protocol; past the cap the
connection is closed at once, not held for the first-line wait. An
RAII OpenSlot gives the place back however the connection ends.
- Each site (IPv4 /24, IPv6 /48) has its own unknown-code budget, 24 a
minute with a burst of 6, checked before the global one and spent on
every unknown code, so emptying the global budget takes many sites,
not one home.
- docs/play-with-friends.md: the new setting and the site budget.
- Tests: the per-address cap (IPv4, IPv6 /64 and /48, off without
per-address limits), the per-site budget (shared within a /24 and a
/48, refilling), and a running relay closing a fifth silent
connection at once with a cap of 4, then letting new ones in once
the held ones go.
Found in yahya-nusseibeh's review of Horizon (H2, 7 Nov).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Pack::read preallocated from a zip entry's declared size and inflated it
with no limit. Shader packs are zips players download, so a deflate bomb
(or a forged size field) in any file under shaders/ aborted the game
with an allocation failure as soon as shaders.properties was read: 512
MiB of spaces zip to 509 KiB and took peak RSS from 4 to 516 MiB; at
1.5 GiB the review measured 1,543 MiB.
- Nothing is allocated from the declared size; a file that declares or
inflates to more than MAX_FILE (64 MiB, far above any .properties,
.lang or GLSL file, with room for the largest textures) reads as
missing, like a file the pack doesn't have. The same pack now stays
at 4 MiB.
- Folder packs are unchanged (files on disk, not inflated).
- Test: a small zip whose shaders.properties unpacks past a 16 KiB cap
reads as missing; the rest of the pack still reads.
Found in yahya-nusseibeh's review of Horizon (M1, 7 Nov).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Pack::read inflated each of up to 512 zip entries to 1 MiB + 1 and kept
them all, and only Pack::from_files then checked the pack's 8 MiB total.
511 entries of zeros take 561 KiB zipped, and the mod registry reads
every submitted .hshader (index::inspect, Kind::Shader) on its blocking
pool: one upload made it hold 1 GiB (measured: peak RSS 3 -> 1,026 MiB),
a few in parallel could get it OOM-killed.
- Each entry is read with room for no more than what is left of
MAX_TOTAL (and its own 1 MiB cap), and refused as soon as it passes
it: "the pack is over 8 MiB", or the file named when it alone is over.
The same package now peaks at 6 MiB.
- A folder pack counts its total the same way.
- Tests: 64 MiB of zeros in a small zip, one file over its cap, and a
folder over the total are all refused.
Found in yahya-nusseibeh's review of Horizon (H1, 7 Nov).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Brings the branch up to date and re-runs CI: verify failed once in
horizon-relay's slow_first_lines_and_too_many_connections_are_closed (a
timing test, 5.6 s against its 3 s on a loaded runner), which this
change doesn't touch.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Vanilla reads network NBT with NbtAccounter's 2 MiB quota, which
charges each tag an estimate of its heap size: at least 8 bytes (EndTag,
the smallest; a byte tag in a list costs 13). Horizon counted only the
bytes read, plus up to 1,000,000 nodes, so a crafted server could send a
1 MB list of byte tags that vanilla refuses and have Horizon build ~1 M
32-byte Tags from it, about 16 times the memory.
- buf::Reader::nbt caps a network NBT value at NBT_QUOTA / 8 = 262,144
tags (NBT_MAX_TAGS). Every value vanilla accepts has at most that
many, so nothing vanilla accepts is refused, and one value's tags now
take at most ~8 MiB here.
- custom::validate checks component values with the same reader, so it
follows.
- Test: a list of 262,143 byte tags (with its root, 262,144) reads; one
more is refused although it is a few hundred KB.
A full port of NbtAccounter's per-type charges would be closer still;
this bound needs only its smallest charge, so it can't refuse what
vanilla takes.
Found in yahya-nusseibeh's review of Horizon (L2).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
The dedicated server and a LAN host decrypt each client's
ServerboundKey with the server's private exponent. That used
square-and-multiply, which multiplies only on the exponent's set bits,
with a data-dependent final subtraction in each Montgomery product, and
no blinding: a client controls the ciphertext and can time the reply,
the classic extra-reduction channel. The key is regenerated every start
(as in vanilla) and a recovered one still needs an on-path position,
but vanilla's JCE blinds this operation.
- Mont::mul subtracts n through a mask chosen from the overflow and
the borrow of t − n, never a branch (Big::sub_with_borrow,
Big::select). Every product takes the same steps.
- Mont::pow_secret: a square and a multiply for every bit of the
exponent's limbs, the product kept by a mask. RsaPublicKey::
apply_secret uses it; RsaPrivateKey::decrypt and ::sign call that.
Public-key verification and encryption stay on the faster pow.
- Big::from_be_bytes no longer skips zero bytes: it parses the private
exponent on every call, and the result is the same.
- Test: pow and pow_secret agree with a reference modular power, for
moduli near a limb boundary (the overflow case) and exponents with
leading zero limbs. The 11 existing RSA tests (OpenSSL signature,
known-key decryption, generated keys) pass unchanged.
No wire or parity change. Multiplicative blinding would be the more
complete fix once a modular inverse exists.
Found in yahya-nusseibeh's review of Horizon (L1).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
After a refused login the connection stayed open, so within the 30 s
login window a client could send as many AUTH packets as it liked: with
the 16 connections allowed at once, thousands of guesses a second
against an RCON port on 0.0.0.0, each adding a supervisor log line.
Vanilla does the same, but this module already departs from vanilla for
safety (constant-time comparison, login deadline, client cap), and this
belongs with those.
- A refused login (wrong or empty password) answers as before, waits
REFUSED_PAUSE (1 s) and closes the connection. Its place stays taken
meanwhile, so the client cap also bounds how fast passwords can be
tried: at most 16 guesses a second instead of thousands.
- A command before the login is still refused without closing, and the
wire protocol is unchanged; standard RCON clients connect again after
a refusal.
- Tests: a wrong and an empty password each end their connection after
the pause; a failed login after a successful one does too. The pause
is a Limits field so tests use 200 ms.
Found in yahya-nusseibeh's review of Horizon (L3).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
The server runs two async workers and keeps SQLite on tokio's blocking
pool (lib.rs, `blocking`), but /hub/vote, /hub/votes and /hub/report and
the token-protected /v1/export/* still called app.store() on the
workers. While a blocking-pool task holds the store mutex (a large batch,
the daily upkeep), two votes or reports from different addresses park
both workers on it and every handler stops answering, /healthz
included; per-address limits don't help, it takes two requests.
/hub/votes also held votes_cache's lock while waiting for the store.
- hub::vote and hub::votes run their store calls through blocking();
votes reads the cache, releases its lock, counts on the blocking pool
and stores the result after.
- intake::receive checks the day's count and queues the report in one
blocking() call, under one store lock as before; report marks the
issue filed through blocking().
- export::benchmarks and export::hardware read through blocking().
- A failed blocking task answers 503 "try again later" through each
module's own refusal (the hub's keeps its CORS headers).
Behaviour is unchanged otherwise: the crate's 58 tests pass as they are.
Found in yahya-nusseibeh's review of Horizon (M1).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
horizon-port-pack read jar entries with read_to_string/read_to_end and
no limit. zip stops an entry only at the size it declares, which can be
4 GiB (more with zip64), and the launcher runs check::check in its own
process on every Java mod a mod-browser search lists, without a click.
So one published mod with a highly compressible mods.toml could abort
the launcher of everyone whose search showed it. A 522 KB jar whose
mods.toml inflates to 512 MiB made `horizon-port-pack check` reach
594 MiB and abort; with this change it peaks at 9 MiB and refuses the
jar.
- crate::entry: read()/read_text() with a per-entry cap, and a Budget
for a running total over one pass of an archive, so many entries
under their cap can't add up either. Limits: 16 MiB per text entry
(JSON, TOML, the manifest), 64 MiB per file kept as it is, 1 GiB per
archive pass. Only bytes actually read count; an entry declaring more
than what is left is refused unread.
- Every zip read in the crate goes through it: jar::read's metadata
(a too-large file refuses the jar), carry's namespace scan and file
copy, worldgen's piece counts, structure-set and biome tags,
processors, upgrade::read_pack and import's JSON index. check.rs kept
its 16 MiB per-class cap and now also refuses a jar whose classes
together unpack to more than 1 GiB (a thousand entries just under the
per-class cap was the same attack).
- Tests: the cap and the running total (declared or not, refused
entries counting), UTF-8, and check() refusing a small jar whose
mods.toml unpacks past 16 MiB.
No parity change: real mod files are far under these limits.
Found in yahya-nusseibeh's review of Horizon (H1).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
launcher/Cargo.lock pinned rustls 0.23.45 (through ureq 2.12), which
cargo audit flags with RUSTSEC-2026-0346 (client session Debug impls
expose TLS secrets), -0347 (buffered data sent to the outer server after
an ECH rejection), -0348 (Brotli certificate decompression) and -0349
(post-quantum downgrade from an unauthenticated ServerHello), all fixed
in 0.23.46. The launcher carries Microsoft sign-in tokens over this
stack.
- `cargo update -p rustls --precise 0.23.46`: rustls alone moves.
- cargo audit on the new lockfile: no vulnerabilities (8 informational
warnings: unmaintained or yanked crates, unchanged).
Found in yahya-nusseibeh's review of Horizon (L4).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
The mod page opened the project named by HORIZON_LAUNCHER_PROJECT (a
screenshot aid) and then called std::env::remove_var so it wouldn't open
again. By then the download, check and keyring threads are running, and
remove_var races getenv on any of them (glibc's DNS resolution
included): unsound, which is why edition 2024 makes it `unsafe`. The
launcher is on 2021, so it compiled silently.
- A static flag remembers that the page was opened this run; the
environment is no longer written. Same behaviour: the page opens once,
after the first search that finds it.
Found in yahya-nusseibeh's review of Horizon (L5).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
point() removed the `current` junction before making the new one, so a
failed mklink left the server with no current release, and std quotes an
argument only when it holds a space, so a folder like D:\mc&co\ reached cmd
unquoted and & split the command.
- The new junction is made first as current.switching; the old one is
removed and the new one renamed into place; if that rename fails the
old junction is put back.
- mklink runs as `cmd /D /S /C "mklink /J "link" "target""` with
raw_arg, both paths quoted; paths holding % or " (which keep a meaning
inside quotes) are refused with a message.
- Test (Windows only): real junctions in a folder named with `&`:
current moves v1 -> v2 and reads each release's file, the old release
is untouched, no current.switching is left, and a release whose path
holds % is refused with current unchanged.
Compiled, linted (clippy -D warnings) and tested on GitHub's
windows-latest runner: all 146 horizon-server tests, the two junction
tests among them. The Unix build and tests pass too.
Split from #9129.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
With RELAY_PROXY_PROTOCOL=1 a PROXY line was accepted from any TCP peer,
so a client reaching the port directly could choose the address its
per-address limits count.
- RELAY_PROXY_FROM: addresses or CIDR ranges (IPv4, IPv6, IPv4-mapped
peers handled). A connection from anywhere else is closed unanswered.
- The relay refuses to start (a clear error, exit 1) when the protocol
is on and the list is empty, or when an entry doesn't parse, instead of
silently dropping every connection. Config::from_env returns a Result;
Config::check says why.
- docs/play-with-friends.md lists the setting.
Split from #9129.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
With HORIZON_TELEMETRY_TRUST_PROXY=1 and no
HORIZON_TELEMETRY_CLIENT_IP_HEADER, every request would count as the
proxy's address: one shared bucket for /v1/batch, deletions, the live
count and reads, so intake would stop and players' deletion requests
would be refused, with only a line on stderr to say so (martipops's
review of #9129).
- The Dockerfile sets HORIZON_TELEMETRY_CLIENT_IP_HEADER=x-real-ip, the
header the server read first before the setting existed, so the
address the limits count on Railway stays the same.
- TRUST_PROXY=1 without the header now refuses to start (exit 1), as the
relay does without RELAY_PROXY_FROM: a deployment missing it fails its
/healthz check instead of going live with one bucket.
- README: both settings, and the refusal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Every Nightly was signed for the constant "horizon release nightly", and
the version a launcher installed it as came from the unsigned GitHub
title, so anyone able to edit the release could serve an older signed
Nightly as tonight's.
- nightly-build.yml signs SHA256SUMS twice with the same key(s):
SHA256SUMS.sig for the channel, as before, for installed launchers;
SHA256SUMS.v2.sig for nightly-<date>-<commit>, the version the title
names (sign_release.sh ALSO_FOR). Both are verified before publishing.
- horizon-release-sign: nightly_title_version() parses the title
strictly (no fallback to published_at); nightly_digest() takes that
version and checks SHA256SUMS.v2.sig for exactly it; is_nightly_build();
SIGNATURE_V2. `verify --signature <file>` checks the v2 file.
- Every installer that checks a Nightly moves in the same step: the
launcher, horizon-server update and packaging/server/install.sh refuse
a Nightly whose title doesn't parse and install it only with a v2
signature for that version. install.ps1 checks no signature (only
GitHub's SHA-256), so it has nothing to move.
- An older Nightly still installs on request as a rollback with a
warning; the launcher's update never moves the selection back to it,
and horizon-server prints a rollback notice for --version.
- Nightlies from before this change have no v2 signature: the refusal
now says so and names `horizon-server rollback`, and docs/security.md
"Nightly signatures" describes the changeover (martipops's review of
#9129). The launcher's new messages go through trf(), in every
language table in English until translated.
- tests/install_sh_signature.sh: a v2 Nightly installs as its version; a
Nightly with only the channel signature, an older Nightly retitled as
a newer one, and an unreadable title are refused (all three installed
before this change).
Split from #9129, as martipops's review suggested.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
import.rs inflated every entry of a dropped .mrpack or game-folder zip
with no byte limit, so a few kilobytes of zip bomb could fill the disk
before the half-made instance was removed.
- fill() counts the bytes actually inflated across all entries (a zip's
sizes are its own claim) and stops past MAX_IMPORT (8 GiB); the error
removes the instance as before.
- The cap sits in fill(), which import_any() calls for both
import_into() and main's newer import_mrpack_into() (a Java instance
takes every path a pack carries), as martipops's review of #9129
asked when rebasing.
- The message goes through trf(), in every language table in English
until translated.
- Tests with a 16 KiB limit: a compressed bomb is refused and leaves
nothing, for a shared instance and for a .mrpack; the limit is for all
files together.
Split from #9129.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Once an entry has owner_ids, only an ID in them makes an owner, so adding
a co-owner to `owners` alone, the obvious edit, would silently lock them
out, and the refusal would list their own login among the owners
(martipops's review of #9129).
- Entry::check_fields refuses an entry whose owner_ids is non-empty and
not as long as owners, with how to find the missing ID, so the registry
CI catches it when the change is made.
- docs/api/owner-update-v2.md: the registry repository has no
owner-update workflow yet, so nothing merges on its own today; and the
backfill script runs only once a maintainer has read it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
Owners were GitHub logins, and a login can be claimed by someone else once
its owner renames or deletes the account. For a project without a key, a
reclaimed login could publish an owner update that merges on its own.
- Entry gains `owner_ids` (#[serde(default)], omitted when empty). The
form records the lister's ID for new projects (file and repository
listings). The registry review notes any change to it.
- ownership(): when an entry has owner_ids, only an ID in them is an
owner, so a matching login on another account is refused. Without
owner_ids (older entries) the login matches, but the update always waits
for a reviewer and the pull request says so.
- The automatic owner update requires an ID match.
- update_trailer is v2: `owner-update-v2` message with the submitter's
numeric ID, and `format: v2` / `submitter_id:` lines in the trailer.
- tools/registry_backfill_owner_ids.py: one-off, dry run by default,
resolves owners' logins to IDs, cross-checks them against the IDs the
form recorded in the entry's commits, and leaves alone (and prints)
any entry it can't resolve completely.
- docs/api/owner-update-v2.md: what the registry repository's
owner-update workflow must check, and the deployment order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 526bfcbbe1)
With TRUST_PROXY=1 the rate limits were keyed on X-Real-IP, else the last
X-Forwarded-For entry, whatever the platform guarantees. Railway's edge
has let clients set X-Real-IP in the past, and puts the CDN's address
there when Fastly is in the path.
- HORIZON_TELEMETRY_CLIENT_IP_HEADER names the one header the proxy
overwrites (x-real-ip on Railway); its last comma-separated entry is
the client. No other header is read.
- TRUST_PROXY=1 without it counts the TCP peer, and the server prints a
warning at start saying every client then shares one bucket; a header
set without TRUST_PROXY is reported as ignored.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit bd420535e0)
The server runs two async workers and did every SQLite call on them, and
GET /v1/aggregate had no rate limit and cached per `days` (366 keys),
while DELETE /v1/installs/<any id> emptied every cache: a cheap way to
stall every handler.
- blocking(): batch storage (zstd inflate included), install deletion,
the totals, the benchmarks summary, the live history, the upkeep and
the live count's minute sample run on tokio's blocking pool.
- /v1/aggregate and /v1/benchmarks/summary take a per-address read
bucket (READS_PER_HOUR, 600); uncached totals builds have a global
budget (AGGREGATE_BUILDS_PER_HOUR, 600) and answer 503 past it.
- AGGREGATE_DAYS (e.g. 7,30,90,366) restricts the windows answered; unset
keeps every value from 1 to 366. It is left unset by default because
the website's own requests (private horizon-site) couldn't be checked.
- A deletion invalidates the cached totals only when it deleted rows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit a29c16a169)
/nightly, the triage commands and /claim decided who may act by the
comment author's login. GitHub hands a renamed or deleted account's login
to whoever claims it next, so a reclaimed reviewer login could pick
unreviewed pull requests into the signed Nightly.
- trusted-reviewers.txt lines become `<id> <login>`; one shared parser
(nightly_set.read_reviewers) for all three commands. Any other line,
bare logins included, grants nothing and is reported on stderr.
`paused` keeps its meaning.
- All three workflows pass AUTHOR_ID (github.event.comment.user.id);
without it the scripts refuse. Ludovic is OWNER_ID, and his threads are
recognised by user.id in triage.
- tools/ci/resolve_reviewer_ids.py fills in the IDs (and OWNER_ID);
--check reports renames and deleted accounts.
No review requirement is added: a Nightly still takes any open pull
request to main, as decided on 2026-10-30 (NoReviewNeededTests).
Split from #9129, as martipops's review suggested.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
command() still passed read_reviewers(reviewers).logins to apply() after
c527d60b7 dropped that parameter, so every /nightly comment that got past
authorize(), status included, ended in TypeError and was never answered.
- Drop the leftover argument.
- Test: command() run as nightly-command.yml runs it, with GitHub and the
state branch stubbed, answers /nightly status. It fails on main with the
TypeError.
Split from #9129, as martipops's review suggested.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
max_bridges compared against bridges already running, so joins waiting
(up to attach_wait) for their host's attach all passed the check and a
burst could exceed RELAY_MAX_BRIDGES.
- BridgeSlot, taken under the lookup lock when a guest is matched with
its host, counts toward max_bridges from then on; dropping it gives it
back, so every way out of a join (no attach, a refused or forged
attach, an error inside bridge(), a panic) releases it.
- counts().bridges now includes matched joins awaiting attach.
- tests/bridge_slots.rs: a waiting join holds its slot (a second guest
gets Full); the count returns to 0 after a timeout, after a forged
attach, and after a bridge closes; refusals never take one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 91d35fcae0)
parse_date multiplied and added the fields of banned-players.json and
banned-ips.json dates as plain i64, so a huge year, month, day or time
panicked in a debug build and wrapped in a release one, turning a ban's
expiry into an arbitrary date. Every step is now checked, years are bounded
before days_from_civil, and a date out of range is None, like one that
doesn't parse. Lenient month overflow (month 13) still works.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 184ea41194)
write_private (the control API's .horizon/api.json, the game's control
endpoint, the management protocol's generated key) opened the path with
create+truncate and mode 0600, which applies only when the file is new:
an existing, wider-readable file kept its permissions and got the new
token, and a link at the path was followed.
- horizon_server_files::private_file::write_private, shared by
horizon-server and horizon-client: writes a create_new 0600 temporary
file beside the path, syncs it and renames it over the path (a link
there is replaced, not followed); the temporary file goes on failure.
- Tests: an old 0644 file comes out 0600; a link's target is untouched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 9cb9d76054)
The TCP control endpoint's token (it gates full control of the running
game) was two SipHash outputs of RandomState over the time and the process
ID. RandomState's keys are OS-seeded, but std doesn't promise it is a
cryptographic generator. It now comes from getrandom::fill, as
horizon-server's and the relay's tokens do (getrandom 0.3, already in the
lockfile, added to horizon-client-lib, which compiles control.rs).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit aa89153fc0)