Skip to content
Buy · $14.99 Opens the GitHub repository in a new tab. The signed build is a separate download; see the README for what is in each.
Changelog

Bastion 1.13.0

Version 1.13.0 ·

Added

  • A shield in front of the provenance badge, and a seventeenth package behind it. The badge 1.12.0 introduced now carries checkmark.shield.fill at its own teal tint, both in a server's pane and in the catalog row. Badge gained an optional glyph to do it, defaulted to nothing, so every other call site renders exactly as it did.

    @mgcrea/mcp-x published 0.3.0 from GitHub Actions in the meantime, and its attestation names the repository the entry already links, so seventeen of the twenty-three npm entries carry the badge rather than sixteen. Checked against the registry rather than assumed, which is the only way the claim is worth anything.

Fixed

This release is mostly a hardening pass over the gateway, the supervisor and the purchase path. Several of the entries below are reachable by anything that can open a connection to the port, so they are worth reading before deciding to defer the update.

  • One crafted request could take the whole app down, before it was ever asked who was sending it. Int("-1") parses. The fill loop was then skipped, body.count >= declared passed, and body.prefix(-1) hit Collection.prefix's own precondition — which kills the process, not the connection. It ran on the connection thread BEFORE the Host, Origin and bearer-token checks, so it needed nothing but the ability to reach the port: every client session and every supervised child went down with it. Reproduced with a single request against a running build. The gateway now answers 400 and keeps serving.

  • A progress frame could be written into a different client's response. A frame is looked up under the supervisor's lock and delivered outside it, from the child's reader thread. If the reaper expired the waiter and resumed the connection thread in that window, the connection's own defer could close(2) the socket before the reader's write landed — and a free descriptor number is one the kernel is entitled to hand to the next accepted connection. The write then landed in somebody else's response.

    HTTPStream now tracks a closed flag under its own lock, set by that defer before the close runs and checked by every write. A frame is either written in full to a descriptor that is still ours, or not written at all.

  • A double-spawn race could leave an orphaned server holding a profile's credentials. ensureRunning() was check-then-act, so two connection threads that both found a dead child could both start one; nothing ever terminated the loser. Worse, the orphan's eventual exit tore down the child that had won, because childExited cleared the pending state unconditionally.

    A semaphore now serialises start-and-handshake, and childExited is a no-op unless the exiting process is the instance's current one. The idle-sweep timer moves under the same lock, since stop() is reached from three threads and every other field there was already guarded.

  • Four gaps in the remote-server OAuth flow. PKCE.init and randomState discarded SecRandomCopyBytes' status, so a CSPRNG failure produced an all-zero verifier or state in silence — a predictable challenge and a predictable CSRF token. That is now fatal, matching GatewayToken.mint. The callback checked error before state, so anything that could reach the ephemeral loopback port could abort an authorization in flight and have it read as the provider's refusal; state is checked first now. resourceMetadataURL split a 401 challenge on every comma, truncating a quoted resource_metadata value containing one, and is now a quote-aware scan. And the callback's accept loop polled with a deadline while the one-byte recv after accept had none, so a browser's speculative pre-connect that sent nothing parked the authorization until the app restarted. Each fix ships with a scripts/remote-check.swift case that fails without it.

  • The SSE parser would buffer without limit, and rescanned from the start on every chunk. Every other reader in the app bounds what it will take from an untrusted source; this one, fed straight from URLSession by a remote server, did not — and its rescan made a long stream quadratic. Both are now handled the way Supervisor.readLoop already handled its own: a 32MB ceiling, and a scan that resumes two bytes before the last cut.

  • A client config backup kept the previous bearer token at the original file's permissions. The backup written ahead of a rewrite inherited the source's mode, so re-wiring a world-readable config left a sibling .bastion-backup holding the OLD token, readable by anyone, indefinitely. It is now chmod'ed 0600 like the file it backs up.

  • An install could hang forever, and the row could never be retried. Both the install and the update-check subprocess read to EOF with no deadline, so a registry that accepted the connection and then said nothing parked the call indefinitely. Because running[server.id] is cleared only when the task returns, every retry was refused for the life of the app while the row sat on "Installing…" with no way to dismiss it. A watchdog now SIGTERMs after five minutes and the call surfaces a named timeout.

  • A locked keychain told every client to re-wire itself. identify(_:) ran on every request ahead of everything else, doing a SecItemCopyMatching over the whole account namespace plus a decrypting read per issued client. That set changes only when a client is wired or unwired, so it is cached for 60 seconds now and invalidated explicitly on issue and revoke.

    The cache also separates two states the old Optional collapsed into one: a token Bastion does not know (the client's problem, still 401) and a keychain that will not answer at all (this app's problem, and usually transient). The second answers 503 now, instead of sending the owner of a locked keychain off to re-wire every client they have.

  • A revoked licence could be mailed out again, and a dispute could restore the wrong one. Five gaps in the purchase webhook, with the schema to support them. fulfil re-sent a revoked licence's key on any redelivery past the cooldown — or on a dashboard "Resend" — under a note promising a refund that had already been paid. charge.dispute.closed with status won restored ANY revoked licence for that payment intent, including one revoked by an unrelated refund; it is scoped to revoked_reason = 'disputed' now. The only idempotency key was stripe_session_id, which stops a second licence but not a second email, so every webhook event id is recorded once handled and a duplicate delivery is a no-op. checkout.session.async_payment_succeeded and its failed twin were unhandled, so a delayed-notification method — SEPA and its relatives — could charge a customer and mint nothing; both route to the same path. And /thanks served a licence key with no cache-control, while the 404 page hardcoded the site's host instead of reading the SITE_URL binding that was declared and never read.