Releases: swayyaam/OSCTF
Release list
v0.3.0 - Plugin-first, and a stable API
The version that makes plugin-first real. Until now every extensible interface had exactly one
implementation compiled into the core. v0.3 adds an out-of-process plugin loader: a third party
registers ADDITIONAL implementations - an OIDC login, a scoring curve, a Discord notifier, a new
challenge type - as standalone executables the core discovers, launches, supervises, and calls
over a versioned gRPC ABI. A plugin author edits nothing in core and opens no PR against it.
A no-plugin deployment behaves exactly as v0.2 did. Plugins are strictly additive, the migrations
are additive, and /api/v0 keeps answering.
API v1 and API tokens
- /api/v1 is the canonical, semver-governed surface. /api/v0 remains as a deprecated alias
serving the identical handlers - there is no second handler set that could drift - and carries
Deprecation/Sunset headers. - Scoped API tokens for clients that are not a browser. The plaintext is shown once and only its
hash is stored; scope is intersected with the owner's role, so an admin scope on a non-admin
account grants nothing. - Every operation the dashboard performs is reachable with a token and NO session cookie, driven
end to end and asserted request-by-request.
The plugin system
- A loader with discovery, strict manifest parsing, an eight-state supervisor with restart
backoff and quarantine, a two-level in-flight budget, and a cancel-then-kill drain. Boot is
asynchronous: the core serves whether or not plugins come up. - A versioned ABI (1.1). A major mismatch is refused at the handshake; minor is forward-
compatible. An Info cross-check at ready quarantines a mispackaged manifest/binary pair at load
rather than surfacing it mid-event. - Four plugin types wired into real requests: challenge-type (verdict computed before the
transaction; deleted or swapped challenges fail closed inside the row lock), scoring (locked at
solve and recorded per solve, so the scoreboard stays recomputable with every plugin down),
notification (a non-blocking bus with bounded queues where every drop is counted), and auth. - POST /admin/plugins/{name}/reload hot-reloads one plugin. A failed reload is not destructive:
the old instance is retained and keeps serving.
External login, and what an auth plugin is allowed to mean
The core owns the CSRF state: it mints it, hands it to the provider, and refuses to start a login
whose authorize URL does not carry it verbatim. The state is single-use and bound to an HttpOnly
cookie, so a captured callback can neither be replayed nor replayed into someone else's browser.
An auth plugin asserts an identity; the core decides what it means, and enforces that field by
field. Provisioning always creates the lowest role. A login attaches to an existing account only
through a binding the core minted, or an email the provider says it VERIFIED. Claims carrying
role/admin/user_id are rejected outright rather than ignored. A malformed claim fails closed.
Installing an auth plugin remains a trust decision on the level of replacing the core binary: it
sits inside the authentication trust boundary, and one with the password capability receives the
plaintext credentials your users type. The checks bound the blast radius; they do not contain a
hostile plugin.
The author kit
plugin/sdk, the shared plugin/abi, and plugin/sdk/contract - VerifyScoring, VerifyNotification,
VerifyChallengeType, VerifyAuth - which boot a built plugin through the real loader and assert the
contract. Five reference plugins ship under plugins/, each its own module: a template, first-blood
(scoring), webhook (notification), regex-flag (challenge type), and oidc (auth).
The exit gate - build a plugin from a clean checkout, against the published SDK, with no replace
and no core source on disk - passes for all five.
Upgrade
No action required for a deployment running no plugins; the migrations are additive and existing
rows are untouched. All new configuration is documented in .env.example and every option has a
working default. If you enable plugins, mount the plugins directory READ-ONLY: a writable one
lets a compromised core drop a binary for the next boot to launch as the platform, and the loader
now warns about exactly that.
There are no API stability promises before v1.0. See CHANGELOG.md for the full entry.
v0.3.0-rc.1 - plugin host, for plugin authors
A PRE-RELEASE of the v0.3 line. It is cut so plugin authors can consume the SDK at a real
version instead of a branch pseudo-version. v0.3 is NOT complete - do not read this as the
v0.3 release.
The module path moved to github.com/swayyaam/OSCTF. Pin this tag explicitly:
go get github.com/swayyaam/OSCTF@v0.3.0-rc.1
A bare go get github.com/swayyaam/OSCTF still resolves to v0.2.4, which predates the move
of the Go module to the repository root and therefore exposes no importable packages. That
resolves when a stable v0.3.0 ships; until then, pin the tag.
WHAT IS IN IT
- The plugin host: discovery, manifest validation, an 8-state supervisor, a two-level
in-flight budget, cancel-then-kill drain, restart/backoff/quarantine, and an identity
cross-check at ready that turns a manifest/binary type mismatch into a load-time
quarantine rather than a mid-event Unimplemented. - Three of the four plugin types wired into real requests: challenge-type (verdict computed
before the transaction, with deleted/swapped challenges failing closed inside the row
lock), scoring (locked at solve, recorded per solve, with an off-read-path repair worker
so the scoreboard stays recomputable with every plugin down), and notification (a
non-blocking event bus with bounded per-subscriber queues and drop-newest, where every
drop is counted rather than silent). - The per-challenge type_config channel: author-time ValidateConfig with per-field errors,
normalized storage, and the stored config passed to CheckFlag at submit. - API v1 as the canonical surface with /api/v0 kept as a deprecated alias, plus scoped
bearer API tokens. - The author kit: plugin/sdk and the plugin/sdk/contract verification helpers
(VerifyScoring, VerifyNotification, VerifyChallengeType).
WHAT IS NOT IN IT
- Auth plugins. The registrar's auth arm returns nil, so no auth plugin can register, and
the return-path validation that must ship with it is not written. This is milestone M3. - The reference plugin set. No plugin has yet been built against a published tag with no
replace directive and no core source on disk - the gate this tag exists to make runnable. - The admin Plugins page, the API-token UI, and provider login buttons (M4).
- An adminReloadPlugin endpoint. The supervisor's hot-reload path has no caller.
- The M5 release tier: a plugins CI job, plugin/token/OIDC e2e coverage, and a v0.3.0
CHANGELOG entry.
No database-schema or API-breaking change relative to v0.2.4 for a deployment running no
plugins; that path is unchanged. There are no API stability promises before v1.0.
v0.2.4 - redis-unavailability hardening
Fixes two bugs that affect anyone running v0.2.3 or earlier, both of which surface at event
time when Redis becomes unavailable. No migration and no config change on upgrade.
-
The API-token rate limiter failed OPEN when Redis was unavailable: a Redis blip silently
removed the per-token throttle entirely — the credential built for automation losing its
limit exactly when the platform is already stressed — while login/register/submit failed
closed as a bare 500. Now all four (login, register, submit, token) fail closed with 503 +
Retry-After, and the condition is logged and counted distinctly
(osctf_ratelimiter_unavailable_total) so "Redis is down" reads differently from "you're
being throttled". Pinned by handlers.TestLimitFailsClosedWhenLimiterUnavailable. -
The scoreboard went dark (500) when Redis was unavailable instead of degrading. A live read
now degrades to a bounded, counted Postgres recompute
(osctf_scoreboard_degraded_served_total) — a slightly slower board, not no board — while a
frozen read stays fail-closed, because a frozen snapshot lives only in Redis and has no
Postgres authority to fall back to (the two paths behave differently under the same outage,
on purpose). Pinned by
scoreboard.TestScoreboardRedisOutageDegradesButFreezeFailsClosedIntegration.
Both fixes backport cleanly from the v0.3 line (they touch code unchanged since v0.2.3).
Operator action on upgrade: none. No database-schema or OpenAPI change; the fixes are
behavioural. New metrics: osctf_ratelimiter_unavailable_total (limiter failing closed, by
scope) and osctf_scoreboard_degraded_served_total (board recomputed from Postgres during a
Redis outage).
v0.2.3 - scoreboard consistency by construction
Closes #6: the served scoreboard could intermittently disagree with the solve log — a rare
rest=0 fromscratch=500 miss the soak reproduced at ~1 in 8 two-minute runs. It was a
durability gap, not a race: the board's consistency rested on a best-effort per-solve
recompute with no guaranteed repair (no outbox, no retry, no periodic recompute in prod), so
a missed/slow/preempted recompute left the board stale until an unrelated tick.
Fixed by read-repair: a served snapshot records the valid-solve count it was computed from,
and a read recomputes before returning if the log has moved past it. The served-equals-log
invariant is now structural, not timing-dependent. Proven by a negative control
(-break-readrepair): with read-repair removed the mismatch reappears at ~the pre-fix rate
(3/5 under -race); with it on the invariant cannot fail by construction (0/30, 0/5).
Two supporting fixes found while triaging #6:
- scoreboard recompute no longer holds the mutex across its DB reads + Redis write (the
third "lock held across I/O" latency bug; see AGENTS.md), guarded on a data-derived count. - the submit/admin recompute no longer runs on the request context, so a client disconnect
can't abandon the board update for an already-committed solve.
Also in this release: CI Go module/build cache fixed (keyed on api/go.sum, not the missing
repo-root path) and all Actions moved off the deprecated Node 20 runtime.
Operator action on upgrade: none. One new read-only query; no OpenAPI or database-schema
change. New metrics: osctf_scoreboard_stale_reads_total (read-repairs) and
osctf_scoreboard_stale_served_total (bounded fallback — alert on it).
v0.2.2 - concurrency hardening
Fixes a set of concurrency and data-corruption bugs (see CHANGELOG.md for impact
and operator action per item):
- team stranded with a non-member captain under concurrent leaves (+ startup self-heal)
- max team size bypassable under concurrent joins
- post-freeze solves could leak into the frozen scoreboard (re-check + SETNX)
- event-end teardown was not phase-gated
- host-port leak via lost instances
- login blocked for a shared-NAT venue (per-IP login limit, GitHub issue #4)
- sign-in burst could OOM the host via unbounded argon2id hashing (GitHub issue #3)
Found by two test tiers built this cycle - an in-process soak harness and a
randomized property-test suite (per-step invariants over adversarial sequences) -
alongside the auth policy matrix and enumeration/flag-containment scanners from v0.2.1.
Operator action required on upgrade: none. A startup consistency check repairs any
team already left with a non-member captain, and every other fix applies on deploy.
No OpenAPI or database-schema change. Optional: tune OSCTF_LOGIN_IP_* /
OSCTF_REGISTER_IP_* and the process RLIMIT_NOFILE for large public events.
v0.2.1 - security and reliability hardening
Security fixes (see CHANGELOG.md for impact and operator action per item):
- frozen scoreboard leaked via getTeam/getUser (+ fail-safe freeze visibility)
- unauthenticated WebSocket denial of service (connection/handshake caps)
- instance extend after the event ended
- session revocation evadable via reverse-index drift
- registration blocked for venues on one NAT (per-IP sign-up limit, GitHub issue #1)
- registration/login burst could OOM the host via unbounded argon2id hashing
(concurrency-capped semaphore + 503/Retry-After load-shed, GitHub issue #3) - per-team bridge reclaimed after upgrade (network garbage collection)
- per-instance flags exposed via the admin submissions view (+ historical redaction, migration 0004)
Also: reliability fixes (reconcile clock skew, scheduler lock scope and port reaper,
graceful shutdown, WS/REST scoreboard consistency) and a large test-coverage expansion.
Operator action required on upgrade: the per-instance flag redaction (migration 0004,
runs on boot) and the WebSocket ulimit guidance. See the CHANGELOG "Security" and
"Upgrade notes" sections.
v0.2.0 - Dynamic per-team instances
Per-team isolated challenge instances with an in-process scheduler: mark a
container challenge per_team and each team starts its own container (own port,
own unique flag, per-team network) with spawn/expire/extend/quota handled
automatically. Adds per-instance dynamic flags with sharing detection, the
runtime hardening pass (read-only rootfs + tmpfs, per-team networks, egress
control), an admin instances fleet view, and per-team example challenges.
Backwards compatible: a v0.1 all-shared/static event upgrades in place and
behaves identically. Migration 0002 is additive and non-destructive.
All CI green: unit + testcontainers integration (race), dockerint runtime
(incl. cross-bridge network-isolation probe), lint, web, image, compose smoke,
and Playwright e2e (three v0.1 flows unchanged + the per-team instance flow).
v0.1.0 - MVP
One person can host a real CTF for ~100 participants on a single server:
git clone && docker compose up yields auth, teams, a challenge board with
seeded examples, flag submission with scoring, a live scoreboard, and an admin
panel — no cloud account, no license key.
Highlights: email/password auth (argon2id, revocable Redis sessions); teams;
event window + freeze; standard/container challenges with attachments; static +
dynamic scoring; rate-limited logged submissions; live WebSocket scoreboard;
Docker challenge runtime; full admin panel; embedded React SPA; 8 seeded example
challenges; one-command compose deploy; /healthz, /readyz, Prometheus metrics.
Licensed under Apache-2.0. See CHANGELOG.md for details.
No API stability promises before v1.0.