microsandbox-rb 0.17.3 → 0.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -1
- data/Cargo.lock +81 -61
- data/DESIGN.md +134 -41
- data/README.md +4 -2
- data/SECURITY.md +8 -0
- data/docs/filesystem.md +2 -1
- data/docs/runtime.md +33 -3
- data/docs/surface.md +4 -1
- data/ext/microsandbox/Cargo.toml +9 -2
- data/ext/microsandbox/src/backend.rs +28 -24
- data/ext/microsandbox/src/error.rs +37 -37
- data/ext/microsandbox/src/lib.rs +50 -2
- data/ext/microsandbox/src/runtime.rs +173 -27
- data/lib/microsandbox/sandbox.rb +39 -20
- data/lib/microsandbox/version.rb +1 -1
- metadata +1 -1
data/DESIGN.md
CHANGED
|
@@ -105,13 +105,27 @@ reached once the result is a Ruby object. What happens to it then:
|
|
|
105
105
|
|
|
106
106
|
- **`Sandbox.create`, `Sandbox.connect_or_create`, `Sandbox.start`, and
|
|
107
107
|
`PullSession#sandbox`** mask interrupts (`Thread.handle_interrupt(Object =>
|
|
108
|
-
:never)`) around the native call
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
:never)`) around the native call only, so the bare `Native::Sandbox` is
|
|
109
|
+
bound to a local before a queued exception or kill can land; an `ensure`
|
|
110
|
+
then **stops the sandbox** when one cuts in before the caller has the
|
|
111
|
+
object — but only a sandbox the call owns (`owns_lifecycle`, the same
|
|
112
|
+
predicate as `connect_or_create`'s block form): one that was already
|
|
113
|
+
running for someone else is left running. The Ruby wrapper is constructed
|
|
114
|
+
*after* the mask, with normal interrupt handling, because `new` runs a
|
|
115
|
+
subclass's `initialize`: a constructor that blocks or runs long must not
|
|
116
|
+
hold a `Timeout.timeout` or `Thread#kill` back until it returns. So the
|
|
117
|
+
cleanup works on the native handle (its `owns_lifecycle` and `stop`), never
|
|
118
|
+
on the wrapper, and covers the window from the native handle's assignment
|
|
119
|
+
through the wrapper's construction: an interrupt delivered as the mask ends
|
|
120
|
+
(before `new` runs), one delivered inside a subclass's `initialize`, and an
|
|
121
|
+
exception that `initialize` raises itself. That cleanup is best-effort, not
|
|
122
|
+
a guarantee for every lost return: a *further* interrupt that arrives during
|
|
123
|
+
the cleanup itself, before the native `stop` has started, or one delivered
|
|
124
|
+
at the method's final return boundary, after the wrapper has been handed
|
|
125
|
+
off but before the caller has assigned it, leaves the teardown to GC (as it
|
|
126
|
+
did before construction moved out of the mask). A `SIGINT` cannot be masked
|
|
127
|
+
this way; it can arrive before the native handle is assigned, and then the
|
|
128
|
+
next case applies.
|
|
115
129
|
- **Every other wrapper** (`ExecHandle`, streams, SSH clients, write sinks, …)
|
|
116
130
|
and every plain result simply becomes garbage; GC frees the wrapper and the
|
|
117
131
|
Rust value's `Drop` closes the session or connection.
|
|
@@ -129,7 +143,9 @@ for the thread's next blocking operation. The four acquisition methods are
|
|
|
129
143
|
the exception: their own `Object => :never` mask sits on top of the caller's,
|
|
130
144
|
so a deferred exception is not raised at the native call's entry there — the
|
|
131
145
|
sandbox is returned and the exception is delivered at the next blocking
|
|
132
|
-
operation after the acquisition.
|
|
146
|
+
operation after the acquisition. (If that blocking operation is inside a
|
|
147
|
+
subclass's `initialize`, the caller does not have the object yet, so the
|
|
148
|
+
sandbox is stopped as above.)
|
|
133
149
|
|
|
134
150
|
`spec/fake_runtime/interrupt_safety_spec.rb` pins all of this against the real
|
|
135
151
|
extension, driving it with a fake `msb` (`spec/support/fake_runtime/`) whose
|
|
@@ -148,23 +164,62 @@ Linux and die with `SIGTRAP` on macOS — and so does *destroying* it, because
|
|
|
148
164
|
the destructors wake the same missing threads (on macOS, replacing an
|
|
149
165
|
inherited `LocalBackend` was enough to kill the child). One rule covers all of
|
|
150
166
|
it: **inherited native state is never used and never dropped in the child. It
|
|
151
|
-
is leaked, once per fork, and replaced by state built in the child.**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
is leaked, once per fork, and replaced by state built in the child.**
|
|
168
|
+
|
|
169
|
+
That needs a reliable answer to "was this built by the process I am?". The
|
|
170
|
+
binding's answer is a **process identity** (`runtime::ProcessIdentity`): the
|
|
171
|
+
pid together with a *fork generation*. At extension load the binding
|
|
172
|
+
registers a `pthread_atfork` child handler whose only work is one atomic
|
|
173
|
+
increment of a counter; the child inherits its parent's counter as plain
|
|
174
|
+
memory, and the handler, running in the child before `fork` returns there,
|
|
175
|
+
makes it one higher. `Kernel#fork` calls libc's `fork`, which runs atfork
|
|
176
|
+
handlers on Linux and macOS, so a child, grandchild, … of the loading process
|
|
177
|
+
has generation 1, 2, …, whatever its pid. Everything the binding builds is
|
|
178
|
+
tagged with the identity of the process that built it, and two identities
|
|
179
|
+
are equal only when both halves are:
|
|
180
|
+
|
|
181
|
+
- the generation tells apart a descendant that was handed an ancestor's pid
|
|
182
|
+
again by the kernel (the pid counter wrapped onto exactly that value)
|
|
183
|
+
without any SDK call in between — pid alone would take it for the
|
|
184
|
+
ancestor;
|
|
185
|
+
- the pid tells apart a process created without running atfork handlers
|
|
186
|
+
(`vfork`, a raw `clone`), whose generation equals its parent's.
|
|
187
|
+
|
|
188
|
+
The handler is async-signal-safe (a lock-free atomic add, no allocation, no
|
|
189
|
+
lock, no Ruby), as a child handler must be; `Relaxed` ordering suffices
|
|
190
|
+
because the forked child is single-threaded until it starts threads, which
|
|
191
|
+
see the counter through the spawn's own synchronization. Handlers cannot be
|
|
192
|
+
unregistered, and Ruby never unloads an extension, so the handler stays
|
|
193
|
+
valid for the life of the process. Should registration fail (`ENOMEM`), the
|
|
194
|
+
generation stays 0 and the identity is effectively the pid alone.
|
|
195
|
+
Three spec-only diagnostics on `Native` expose it: `fork_generation` returns
|
|
196
|
+
the counter, and `fork_identity_equal` / `fork_identity_guard` run the real
|
|
197
|
+
comparisons (`==`, the packed backend word, a handle's ownership check) on
|
|
198
|
+
identities a spec chooses — the only way to pin that both halves decide,
|
|
199
|
+
since a real fork changes both and pid reuse cannot be staged
|
|
200
|
+
(`spec/unit/fork_identity_spec.rb`).
|
|
201
|
+
|
|
202
|
+
Three pieces use the identity:
|
|
203
|
+
|
|
204
|
+
- **Runtime cell** (`runtime.rs`). The runtime and the identity of the process
|
|
205
|
+
that built it live in one heap cell behind one `AtomicPtr`. `runtime()`
|
|
206
|
+
returns the cell's runtime when that identity is ours; otherwise it builds a
|
|
207
|
+
new cell and publishes it with a compare-exchange (a loser of that race in
|
|
208
|
+
the same process drops its own, never-published runtime). There is no mutex,
|
|
209
|
+
so nothing can be inherited locked or poisoned, and no window in which
|
|
210
|
+
pointer and identity disagree. A cell built by a parent process is leaked:
|
|
211
|
+
`Runtime`'s `Drop` joins worker threads that do not exist.
|
|
212
|
+
- **Backend record** (`backend.rs`). The binding records how the default
|
|
163
213
|
backend was chosen — the core's own ladder (environment, profile, config
|
|
164
214
|
file), or an explicit `set_default_backend`/`with_backend` selection — and
|
|
165
|
-
the
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
the identity of the process it belongs to (one `AtomicU64`, pid and
|
|
216
|
+
generation, written and read whole). `ensure_this_process_backend` runs,
|
|
217
|
+
with the GVL held, at the start of every `block_on` and of every synchronous
|
|
218
|
+
entry that reads the backend. The one exception is the runtime installer
|
|
219
|
+
(`Native.install`, `Native.setup`): it never reads the backend, so it runs
|
|
220
|
+
through `block_on_runtime_install`, which skips the check, and a child that
|
|
221
|
+
installs leaves the record alone for its first call that does use the
|
|
222
|
+
backend. In a process whose identity differs from the recorded one it
|
|
168
223
|
builds a fresh backend from the record, swaps it into the core and
|
|
169
224
|
`mem::forget`s the inherited one, along with the backends that open
|
|
170
225
|
`with_backend` scopes had saved for their restore. A scope the parent opened
|
|
@@ -185,20 +240,23 @@ pieces implement it, all keyed on the pid (`runtime::current_pid`):
|
|
|
185
240
|
the error is reported (by `with_backend` as a warning when the block itself
|
|
186
241
|
raised, so that the block's exception is the one that propagates).
|
|
187
242
|
- **`ForkGuarded<T>`** (`runtime.rs`). The payload of every `#[magnus::wrap]`
|
|
188
|
-
type sits in one, tagged with the creating
|
|
189
|
-
it for anything that acts on the value, and in another
|
|
190
|
-
`Microsandbox::ForkedHandleError` (there is no `Deref`,
|
|
191
|
-
access does not compile).
|
|
192
|
-
the
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
243
|
+
type sits in one, tagged with the identity of the creating process. `get()`
|
|
244
|
+
is the only way to it for anything that acts on the value, and in another
|
|
245
|
+
process it returns `Microsandbox::ForkedHandleError` (there is no `Deref`,
|
|
246
|
+
so an unchecked access does not compile). The message names the creating pid
|
|
247
|
+
and the current one; when they are equal — a reused pid — it says so rather
|
|
248
|
+
than naming the same pid twice as two processes. Its `Drop` runs the
|
|
249
|
+
payload's destructor only in the creating process; a child that
|
|
250
|
+
garbage-collects an inherited handle — at the latest when it exits, since
|
|
251
|
+
Ruby frees every wrapped object then — leaks it. Accessors that only copy
|
|
252
|
+
plain data out of the value (`name`, `id`, the `SandboxHandle` snapshot
|
|
253
|
+
fields, `ExecHandle#id`) skip the check through `peek()`, so a child can
|
|
254
|
+
still read the name it needs to open a handle of its own.
|
|
255
|
+
|
|
256
|
+
What a child leaks is bounded: one runtime; once it uses the backend, the
|
|
257
|
+
inherited default backend and the saved backend of each open scope; each
|
|
258
|
+
inherited handle it collects; plus one backend for each parent-opened scope it
|
|
259
|
+
closes. A child that only installs the runtime builds no backend.
|
|
202
260
|
|
|
203
261
|
The mutexes involved (`backend.rs`) are only taken with the GVL held, and
|
|
204
262
|
nothing that runs under them calls into Ruby or releases the GVL — Ruby
|
|
@@ -223,10 +281,45 @@ Limits, by design:
|
|
|
223
281
|
with any threaded native library.
|
|
224
282
|
- **Other libraries.** The SDK no longer touches inherited state; code that
|
|
225
283
|
reaches it some other way can still hang or trap.
|
|
226
|
-
- **
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
284
|
+
- **macOS system frameworks.** On macOS a forked child that has not called
|
|
285
|
+
`exec` cannot use the system frameworks the core reaches for network and
|
|
286
|
+
credential work: CoreFoundation and libobjc are not fork-safe, and the
|
|
287
|
+
child dies — `SIGSEGV`, or `SIGABRT` from libobjc's fork check — inside the
|
|
288
|
+
framework. It is a crash, not an exception; nothing can rescue it. Two such
|
|
289
|
+
calls sit on SDK paths. Every HTTP client the core builds (reqwest, with the
|
|
290
|
+
`system-proxy` feature that the core crate enables; a downstream crate
|
|
291
|
+
cannot turn a Cargo feature off) looks up the system proxy settings through
|
|
292
|
+
SystemConfiguration (`SCDynamicStoreCopyProxies`) while it is built; and a
|
|
293
|
+
pull without explicit `registry_auth:` first looks for registry credentials
|
|
294
|
+
in the Keychain (`keyring`). So in a forked child on macOS, each of these
|
|
295
|
+
kills the child:
|
|
296
|
+
- any use of a cloud backend, however it is chosen (`set_default_backend`,
|
|
297
|
+
`with_backend`, `MSB_BACKEND`/`MSB_API_KEY`, a profile) — the child's
|
|
298
|
+
rebuild of the backend described above builds a client;
|
|
299
|
+
- `Sandbox.create` with an image that is not in the image cache yet, or
|
|
300
|
+
with `pull_policy: :always` (the Keychain lookup, then the registry
|
|
301
|
+
client; with `registry_auth:` only the latter);
|
|
302
|
+
- `Image.load` (it builds a registry client even for a local archive);
|
|
303
|
+
- runtime provisioning: `Microsandbox.install`, `Microsandbox.setup`, and
|
|
304
|
+
the first-use install in `ensure_runtime!`.
|
|
305
|
+
|
|
306
|
+
Whether the parent used the SDK makes no difference: a child of a parent
|
|
307
|
+
that only required the gem dies the same way, and
|
|
308
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` does not prevent it. Calls that
|
|
309
|
+
stay on the local catalog and runtime are unaffected, including
|
|
310
|
+
`Sandbox.create` with an image already in the cache. On Linux the cloud
|
|
311
|
+
case is exercised by `spec/unit/fork_safety_spec.rb` and works (those
|
|
312
|
+
examples are skipped on macOS). On macOS, do this work in a process that
|
|
313
|
+
was not forked — the parent before it forks, or a fresh one
|
|
314
|
+
(`Process.spawn`, or `fork` followed by `exec`) — for example, pull the
|
|
315
|
+
images the workers need in the master before forking them.
|
|
316
|
+
- **Forks that skip atfork handlers.** A process created with `vfork` or a
|
|
317
|
+
raw `clone` keeps its parent's generation, so only its pid identifies it.
|
|
318
|
+
Ruby itself never forks that way — `Kernel#fork` runs the handlers, and
|
|
319
|
+
`spawn`/`system` `exec` at once, which discards all of this state — so
|
|
320
|
+
mistaking such a process for its ancestor needs a native library that
|
|
321
|
+
clones without the handlers and then runs Ruby, together with exact pid
|
|
322
|
+
reuse.
|
|
230
323
|
|
|
231
324
|
`spec/unit/fork_safety_spec.rb` (catalog only, no runtime),
|
|
232
325
|
`spec/fake_runtime/fork_safety_spec.rb` (a live handle over the fake `msb`)
|
data/README.md
CHANGED
|
@@ -209,7 +209,7 @@ share the upstream tag, but the numbers have diverged since and the gem version
|
|
|
209
209
|
is **not** an indicator of the embedded runtime. Ask the build instead:
|
|
210
210
|
|
|
211
211
|
```ruby
|
|
212
|
-
Microsandbox::VERSION # => "0.17.
|
|
212
|
+
Microsandbox::VERSION # => "0.17.5" (the gem's own version)
|
|
213
213
|
Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
|
|
214
214
|
```
|
|
215
215
|
|
|
@@ -245,9 +245,11 @@ constants against this gem's. Every release records its runtime in
|
|
|
245
245
|
| `0.15.0` | `v0.6.14` | adopts upstream `v0.6.10`–`v0.6.14` step by step: bind-mount correctness, guest bootstrap off the kernel command line, DNS pins for deferred domain allows, Linux glibc 2.28 baseline for the prebuilt runtime, legacy ext4 upper-disk resize, `msb_krun` 0.1.32. Parity: `ssh.open_client`/`prepare_server` accept `inactivity_timeout:` (seconds; `0` disables, `nil` inherits the 600s global default) |
|
|
246
246
|
| `0.16.0` | `v0.6.16` | adopts upstream `v0.6.15`+`v0.6.16` step by step: mount fallback ownership, readonly-mount write-probe fix, log retrieval rerouted through the SDK backends, config overlaid by field presence, network-slot recycling. Parity: per-mount `uid:`/`gid:`, and the convergent lifecycle — `Sandbox.connect_or_create`, `#id`, `#wait_for_status`, `#restart`, `#destroy`, `SandboxHandle#connect_or_start`, `SandboxReplacedError` |
|
|
247
247
|
| `0.17.0` | `v0.6.18` | adopts upstream `v0.6.17`+`v0.6.18` step by step: outbound SOCKS4/SOCKS5 proxies (SOCKS5 UDP + credentials), migration-order fix for databases last opened by a `v0.6.15` `msb`, security hardening — plain-HTTP `Host`/`:authority` now checked against domain-rule policies even in non-strict mode, plus fail-closed strict hostname mode. Parity: `proxy:` (`Microsandbox::OutboundProxy` / `SecretSource`), `strict:` |
|
|
248
|
-
| `0.17.1` | `v0.6.18` | gem-only security and hardening release: relative host paths pinned at create time (GHSA-mcw9-9rf6-p4rm), `Volume.fs` path validation, `Cargo.lock` advisory updates, argument-guard and cleanup fixes, `SECURITY.md` |
|
|
248
|
+
| `0.17.1` | `v0.6.18` | gem-only security and hardening release: relative host paths pinned at create time (GHSA-mcw9-9rf6-p4rm), `Volume.fs` path validation (mitigates GHSA-rh2c-vm8j-5gg6), `Cargo.lock` advisory updates, argument-guard and cleanup fixes, `SECURITY.md` |
|
|
249
249
|
| `0.17.2` | `v0.6.18` | gem-only fix and release-pipeline hardening release: an interrupted native call no longer loses its result (a sandbox the call owns is stopped on `Timeout`/`Thread#kill`), release preflight, SHA-pinned workflow actions and `cargo deny` advisory checks, RBS and docs contract specs |
|
|
250
250
|
| `0.17.3` | `v0.6.18` | gem-only fix release, fork safety: a forked child (Puma, Resque, Solid Queue) rebuilds its backend and runtime on its first SDK call instead of hanging or crashing, a handle created before the fork raises `ForkedHandleError` in the child, and nothing inherited from the parent is dropped there |
|
|
251
|
+
| `0.17.4` | `v0.6.18` | gem-only fix release: fork safety identifies a process by pid and fork generation (survives pid reuse), `install`/`setup` leave the backend alone in a forked child, a `Sandbox` subclass's `initialize` runs outside the interrupt mask, and the macOS limit that a forked child must not build network clients is documented; rustls 0.23.46 fixes four TLS advisories (RUSTSEC-2026-0346 to -0349) |
|
|
252
|
+
| `0.17.5` | `v0.6.18` | gem-only security release: dependency updates clear RUSTSEC-2026-0381 to -0410, including the zstd used for image-layer decompression (three zstd advisories remain only on an external-kernel path the runtime never takes, see `CHANGELOG.md`); `SECURITY.md` maps more upstream advisories to gem versions |
|
|
251
253
|
|
|
252
254
|
## Development
|
|
253
255
|
|
data/SECURITY.md
CHANGED
|
@@ -32,12 +32,20 @@ entry in some of them refers to the upstream project's own Ruby gem, not to
|
|
|
32
32
|
|
|
33
33
|
| Advisory | Severity | Affected `microsandbox-rb` | Status |
|
|
34
34
|
|---|---|---|---|
|
|
35
|
+
| [GHSA-rh2c-vm8j-5gg6](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-rh2c-vm8j-5gg6) — `Volume.fs` paths that pass through a missing directory escape the volume root | Critical | `0.7.0`–`0.17.0` | Mitigated in `0.17.1`: every `Volume.fs` / `VolumeInfo#fs` method refuses a path with a `..` segment, in both the Ruby and the native layer, which closes the published bypass (`write`, `mkdir`, `copy` and `rename` through a directory that does not exist). The complete fix is upstream's rewrite of host-side volume access in runtime `v0.7.6`, which also closes the window between checking a path and using it; it arrives with the first gem release on runtime `v0.7.6` or later, and there is none yet. The upstream advisory lists only `microsandbox <= 0.6.16` with no patched version, but the affected resolver is unchanged in every runtime up to `v0.7.5`, so gem `0.17.0` (runtime `v0.6.18`) is affected too. `Volume.fs` first appeared in `0.7.0`. |
|
|
35
36
|
| [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. Tracked for this gem as [GHSA-qrhv-m6hf-9cmg](https://github.com/ya-luotao/microsandbox-rb/security/advisories/GHSA-qrhv-m6hf-9cmg). |
|
|
36
37
|
| [GHSA-cg9x-2vfm-3j58](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-cg9x-2vfm-3j58) — NAT64 addresses can bypass destination-group network policy | High | all versions (runtime `< v0.7.5`) | **Not fixed in this gem yet**; needs the runtime fix. Only exploitable where the host network routes a NAT64 prefix. Mitigate by denying the prefix explicitly (see below) or blocking it at the host. |
|
|
37
38
|
| [GHSA-3mpp-52gc-935r](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-3mpp-52gc-935r) — hostname allowlist bypass via mismatched HTTP `Host` / `:authority` | High | `<= 0.16.0` | Runtime fix included from `0.17.0`. If you rely on a hostname allowlist for HTTPS without TLS interception, also pass `strict: true` (off by default in this gem). |
|
|
38
39
|
| [GHSA-gj24-29j2-3x78](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-gj24-29j2-3x78) — egress allowlist bypass on malformed HTTP/1 request lines | Medium | `<= 0.12.0` | Runtime fix included from `0.13.0`. |
|
|
39
40
|
| [GHSA-4vq3-cjpp-v7fg](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-4vq3-cjpp-v7fg) — guest-controlled symlinks in `msb copy` | High | `<= 0.10.0` (the bundled `msb` CLI) | Runtime fix included from `0.11.0`. |
|
|
41
|
+
| [GHSA-v693-3rfr-6q3q](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-v693-3rfr-6q3q) — IPv4-mapped IPv6 addresses bypass DNS rebind protection | Critical | `<= 0.9.1` | Runtime fix included from `0.9.2` (runtime `v0.6.3`). Affects network-enabled sandboxes. |
|
|
40
42
|
| [GHSA-m8f5-rh7h-vgg3](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-m8f5-rh7h-vgg3) — secret values in world-readable process arguments | Medium | `<= 0.7.0` | Runtime fix included from `0.8.0`. |
|
|
43
|
+
| [GHSA-89j4-256r-3pcq](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-89j4-256r-3pcq) — symlink traversal when importing a snapshot archive | High | `0.5.7`–`0.5.9` (`Snapshot.import` and the bundled `msb snapshot import`) | Runtime fix included from `0.5.10` (runtime `v0.5.8`). |
|
|
44
|
+
|
|
45
|
+
[GHSA-xgg5-hjwq-2gw8](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-xgg5-hjwq-2gw8)
|
|
46
|
+
(domain deny rules bypassed by direct-IP TLS) does not affect any release of
|
|
47
|
+
this gem: it was fixed in runtime `v0.5.0`, and the first release, `0.5.7`,
|
|
48
|
+
embeds runtime `v0.5.7`.
|
|
41
49
|
|
|
42
50
|
### Mitigating GHSA-cg9x-2vfm-3j58 before the runtime fix
|
|
43
51
|
|
data/docs/filesystem.md
CHANGED
|
@@ -42,4 +42,5 @@ through `Microsandbox::Volume.fs(name)` / `VolumeInfo#fs` (a `VolumeFs`). See
|
|
|
42
42
|
|
|
43
43
|
Paths are relative to the volume root; a leading `/` is the volume root, not the
|
|
44
44
|
host root. A path containing a `..` segment is refused with `FilesystemError` in
|
|
45
|
-
every operation.
|
|
45
|
+
every operation. Versions `0.7.0`–`0.17.0` did not refuse such paths and are
|
|
46
|
+
affected by GHSA-rh2c-vm8j-5gg6; see [SECURITY.md](../SECURITY.md).
|
data/docs/runtime.md
CHANGED
|
@@ -114,7 +114,9 @@ backend), chosen the same way as the parent's: an explicit
|
|
|
114
114
|
`set_default_backend`, an enclosing `with_backend` block, or — when neither
|
|
115
115
|
was used — the environment and config file as the child sees them at that
|
|
116
116
|
call. Everything addressed by name works in the child from then on:
|
|
117
|
-
`Sandbox.create`/`get`/`list`/`start`, `Volume`, `Image`, `Snapshot
|
|
117
|
+
`Sandbox.create`/`get`/`list`/`start`, `Volume`, `Image`, `Snapshot` — except
|
|
118
|
+
that on macOS a forked child must not do anything that builds a network
|
|
119
|
+
client (see [macOS](#macos-no-network-clients-in-a-forked-child) below).
|
|
118
120
|
|
|
119
121
|
If the parent's choice cannot be made again in the child (say the child's
|
|
120
122
|
environment now selects the cloud backend without credentials), SDK calls
|
|
@@ -126,6 +128,7 @@ does not depend on the inherited choice and works regardless.
|
|
|
126
128
|
Microsandbox::Sandbox.list # the parent has used the SDK
|
|
127
129
|
|
|
128
130
|
pid = fork do
|
|
131
|
+
# On macOS "alpine" must already be in the image cache (see below).
|
|
129
132
|
Microsandbox::Sandbox.create("job-#{Process.pid}", image: "alpine") do |sb|
|
|
130
133
|
puts sb.exec("echo", ["hello from the worker"]).stdout
|
|
131
134
|
end
|
|
@@ -137,8 +140,8 @@ What does **not** cross a fork is an object created before it. A `Sandbox`,
|
|
|
137
140
|
`SandboxHandle`, `ExecHandle`, `ExecStdin`, `PullSession`, log/metrics/file
|
|
138
141
|
stream, `SshClient`/`SftpClient`/`SshServer`, `AgentClient` or `VolumeFs`
|
|
139
142
|
belongs to the process that created it. In a forked child, any call that acts
|
|
140
|
-
on one raises `Microsandbox::ForkedHandleError` (the message names
|
|
141
|
-
pids); the original keeps working in the parent. Reading `name` or `id` — and
|
|
143
|
+
on one raises `Microsandbox::ForkedHandleError` (the message names the
|
|
144
|
+
pids involved); the original keeps working in the parent. Reading `name` or `id` — and
|
|
142
145
|
the snapshot fields of a `SandboxHandle` — still works, so a child can open
|
|
143
146
|
its own handle:
|
|
144
147
|
|
|
@@ -189,6 +192,33 @@ Things to know:
|
|
|
189
192
|
parent's runtime, backend and whichever inherited handles it collects)
|
|
190
193
|
instead of destroying it — destroying it is what used to crash.
|
|
191
194
|
|
|
195
|
+
### macOS: no network clients in a forked child
|
|
196
|
+
|
|
197
|
+
On macOS, a forked child that has not called `exec` dies — `SIGSEGV` or
|
|
198
|
+
`SIGABRT`, not an exception, so it cannot be rescued — as soon as the SDK
|
|
199
|
+
builds a network client or reads registry credentials from the Keychain
|
|
200
|
+
there. macOS's system frameworks do not support being used after `fork`
|
|
201
|
+
without `exec`, and every HTTP client the SDK builds looks up the system proxy
|
|
202
|
+
settings through them. In a forked child on macOS, do not:
|
|
203
|
+
|
|
204
|
+
- use a cloud backend at all, however it is selected (`set_default_backend`,
|
|
205
|
+
`with_backend`, `MSB_BACKEND`/`MSB_API_KEY`, a profile);
|
|
206
|
+
- `Sandbox.create` with an image that is not in the image cache yet, or with
|
|
207
|
+
`pull_policy: :always`;
|
|
208
|
+
- `Image.load`;
|
|
209
|
+
- install the runtime: `Microsandbox.install`, `Microsandbox.setup`, or the
|
|
210
|
+
first-use install `ensure_runtime!` performs.
|
|
211
|
+
|
|
212
|
+
It makes no difference whether the parent used the SDK, and
|
|
213
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` does not help. Local work is
|
|
214
|
+
unaffected, including `Sandbox.create` with an image that is already cached.
|
|
215
|
+
On Linux the cloud backend works in a forked child.
|
|
216
|
+
|
|
217
|
+
Do that work in a process that was not forked: in the parent before it forks
|
|
218
|
+
(install the runtime and pull the images the workers will use — creating a
|
|
219
|
+
sandbox from the image pulls it), or in a fresh process started with
|
|
220
|
+
`Process.spawn` (or `fork` followed by `exec`).
|
|
221
|
+
|
|
192
222
|
## One runtime line per home
|
|
193
223
|
|
|
194
224
|
The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
|
data/docs/surface.md
CHANGED
|
@@ -59,7 +59,10 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
|
|
|
59
59
|
- **Errors** — the typed hierarchy in [errors.md](errors.md).
|
|
60
60
|
- **Process model** — the SDK may be used before and after `fork`; objects
|
|
61
61
|
created before a fork are refused in the child with `ForkedHandleError`
|
|
62
|
-
(see [runtime.md](runtime.md#forking-puma-resque-solid-queue)).
|
|
62
|
+
(see [runtime.md](runtime.md#forking-puma-resque-solid-queue)). On macOS a
|
|
63
|
+
forked child must not build network clients — cloud backends, image pulls,
|
|
64
|
+
`Image.load`, runtime install — or it crashes (see
|
|
65
|
+
[runtime.md](runtime.md#macos-no-network-clients-in-a-forked-child)).
|
|
63
66
|
|
|
64
67
|
Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
|
|
65
68
|
`env`/`workdir`/`labels`/`user`/`hostname`/`shell`, named `scripts`, the image
|
data/ext/microsandbox/Cargo.toml
CHANGED
|
@@ -8,7 +8,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
|
|
|
8
8
|
# returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
|
|
9
9
|
# The core-crate dependencies below stay on their own tag, which is
|
|
10
10
|
# Microsandbox::RUNTIME_VERSION and moves independently of this version.
|
|
11
|
-
version = "0.17.
|
|
11
|
+
version = "0.17.5"
|
|
12
12
|
authors = ["Super Rad Company <development@superrad.company>"]
|
|
13
13
|
repository = "https://github.com/superradcompany/microsandbox"
|
|
14
14
|
license = "Apache-2.0"
|
|
@@ -25,12 +25,19 @@ crate-type = ["cdylib"]
|
|
|
25
25
|
[dependencies]
|
|
26
26
|
# "rb-sys" exposes `magnus::rb_sys::protect` (rb_protect), which the GVL bridge
|
|
27
27
|
# uses to check interrupts without letting Ruby longjmp over Rust frames.
|
|
28
|
-
|
|
28
|
+
# Default features are off: `old-api` (the free functions that conjure a `Ruby`
|
|
29
|
+
# handle unchecked in release builds) must not creep back, and `io` only gates
|
|
30
|
+
# `magnus::io`, which the extension does not use.
|
|
31
|
+
magnus = { version = "0.9", default-features = false, features = ["rb-sys"] }
|
|
29
32
|
# Low-level Ruby C API — used directly for rb_thread_call_without_gvl2 (GVL
|
|
30
33
|
# release during blocking sandbox calls) and rb_thread_check_ints. Kept in
|
|
31
34
|
# lock-step with magnus's own rb-sys.
|
|
32
35
|
rb-sys = "0.9"
|
|
33
36
|
|
|
37
|
+
# `pthread_atfork`, for the fork generation (runtime::ProcessIdentity). Already
|
|
38
|
+
# in the dependency graph; listed so the binding can call it directly.
|
|
39
|
+
libc = "0.2"
|
|
40
|
+
|
|
34
41
|
# The microsandbox core runtime, wrapped via FFI exactly like the official
|
|
35
42
|
# Python (pyo3) and Node (napi) SDKs. Pinned to the matching upstream tag so the
|
|
36
43
|
# gem is self-contained and buildable anywhere (CI, release containers, end-user
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
//! matching Python and Node.
|
|
17
17
|
|
|
18
18
|
use std::collections::HashMap;
|
|
19
|
-
use std::sync::atomic::{AtomicU32, Ordering};
|
|
19
|
+
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
|
20
20
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
|
|
21
21
|
|
|
22
22
|
use magnus::{function, prelude::*, Error, RModule, Ruby};
|
|
23
23
|
use microsandbox::{Backend, MicrosandboxError, Operation};
|
|
24
24
|
|
|
25
25
|
use crate::error;
|
|
26
|
-
use crate::runtime::{block_on,
|
|
26
|
+
use crate::runtime::{block_on, ProcessIdentity};
|
|
27
27
|
|
|
28
28
|
/// Resolve the ambient default backend, requiring it to be local.
|
|
29
29
|
///
|
|
@@ -99,7 +99,7 @@ fn build_backend(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
//--------------------------------------------------------------------------------------------------
|
|
102
|
-
// Fork safety: the backend
|
|
102
|
+
// Fork safety: the backend record
|
|
103
103
|
//--------------------------------------------------------------------------------------------------
|
|
104
104
|
//
|
|
105
105
|
// `fork(2)` copies only the calling thread. A child of a process that used the
|
|
@@ -114,7 +114,7 @@ fn build_backend(
|
|
|
114
114
|
// So the child never touches what it inherited: the first native call after a
|
|
115
115
|
// fork builds a fresh backend the same way the inherited one was chosen and
|
|
116
116
|
// swaps it in, and every inherited `Arc` is `mem::forget`-ed — leaked once per
|
|
117
|
-
//
|
|
117
|
+
// forked process — instead of dropped.
|
|
118
118
|
//
|
|
119
119
|
// Locking. The three mutexes below are only ever taken on a Ruby thread that
|
|
120
120
|
// holds the GVL, and nothing that runs while one is held calls into Ruby or
|
|
@@ -158,9 +158,11 @@ impl Selection {
|
|
|
158
158
|
|
|
159
159
|
static SELECTION: Mutex<Selection> = Mutex::new(Selection::Ladder);
|
|
160
160
|
|
|
161
|
-
/// The
|
|
162
|
-
///
|
|
163
|
-
|
|
161
|
+
/// The identity of the process whose default backend the core currently holds
|
|
162
|
+
/// ([`ProcessIdentity::to_bits`]: pid and fork generation in one word, so it
|
|
163
|
+
/// is read and written whole). 0 = no native operation has touched the
|
|
164
|
+
/// backend in this process lineage.
|
|
165
|
+
static BACKEND_OWNER: AtomicU64 = AtomicU64::new(0);
|
|
164
166
|
|
|
165
167
|
static REBUILD_LOCK: Mutex<()> = Mutex::new(());
|
|
166
168
|
|
|
@@ -183,7 +185,7 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
|
183
185
|
/// above).
|
|
184
186
|
pub fn ensure_this_process_backend() -> Result<(), Error> {
|
|
185
187
|
// The Ruby exception is built here, after the locks are released.
|
|
186
|
-
adopt_backend(
|
|
188
|
+
adopt_backend(ProcessIdentity::current(), None)
|
|
187
189
|
.map(|_| ())
|
|
188
190
|
.map_err(error::to_ruby)
|
|
189
191
|
}
|
|
@@ -204,23 +206,24 @@ pub fn ensure_this_process_backend() -> Result<(), Error> {
|
|
|
204
206
|
/// Returns `Some(inherited selection)` when `replacement` was installed here
|
|
205
207
|
/// (the caller has nothing left to install), `None` when the caller proceeds
|
|
206
208
|
/// as in any process that already owns its backend. On an error nothing has
|
|
207
|
-
/// changed, `
|
|
209
|
+
/// changed, `BACKEND_OWNER` included, so the next call tries again.
|
|
208
210
|
fn adopt_backend(
|
|
209
|
-
cur:
|
|
211
|
+
cur: ProcessIdentity,
|
|
210
212
|
replacement: Option<&Selection>,
|
|
211
213
|
) -> Result<Option<Selection>, MicrosandboxError> {
|
|
212
|
-
|
|
214
|
+
let cur_bits = cur.to_bits();
|
|
215
|
+
if BACKEND_OWNER.load(Ordering::Acquire) == cur_bits {
|
|
213
216
|
return Ok(None);
|
|
214
217
|
}
|
|
215
218
|
let _rebuild = lock(&REBUILD_LOCK);
|
|
216
|
-
let seen =
|
|
217
|
-
if seen ==
|
|
219
|
+
let seen = BACKEND_OWNER.load(Ordering::Acquire);
|
|
220
|
+
if seen == cur_bits {
|
|
218
221
|
return Ok(None);
|
|
219
222
|
}
|
|
220
223
|
if seen == 0 {
|
|
221
224
|
// Nothing was inherited; the core resolves its default lazily, in this
|
|
222
225
|
// process, on first use.
|
|
223
|
-
|
|
226
|
+
BACKEND_OWNER.store(cur_bits, Ordering::Release);
|
|
224
227
|
return Ok(None);
|
|
225
228
|
}
|
|
226
229
|
|
|
@@ -233,14 +236,14 @@ fn adopt_backend(
|
|
|
233
236
|
// The inherited default must never be dropped (see above).
|
|
234
237
|
std::mem::forget(microsandbox::swap_default_backend(fresh));
|
|
235
238
|
// Neither may the backends that open `with_backend` scopes saved for their
|
|
236
|
-
// restore. The entries themselves stay —
|
|
239
|
+
// restore. The entries themselves stay — owner and selection — so a scope
|
|
237
240
|
// the parent opened can still be closed here (see `pop_default_backend`).
|
|
238
241
|
for scope in lock(backend_scopes()).values_mut() {
|
|
239
242
|
std::mem::forget(scope.previous.take());
|
|
240
243
|
}
|
|
241
244
|
let inherited =
|
|
242
245
|
replacement.map(|selection| std::mem::replace(&mut *lock(&SELECTION), selection.clone()));
|
|
243
|
-
|
|
246
|
+
BACKEND_OWNER.store(cur_bits, Ordering::Release);
|
|
244
247
|
Ok(inherited)
|
|
245
248
|
}
|
|
246
249
|
|
|
@@ -261,7 +264,7 @@ fn set_default_backend(
|
|
|
261
264
|
// A forked child that starts by selecting a backend: the inherited default
|
|
262
265
|
// is forgotten and this selection installed in its place, without ever
|
|
263
266
|
// rebuilding the inherited one.
|
|
264
|
-
if adopt_backend(
|
|
267
|
+
if adopt_backend(ProcessIdentity::current(), Some(&selection))
|
|
265
268
|
.map_err(error::to_ruby)?
|
|
266
269
|
.is_some()
|
|
267
270
|
{
|
|
@@ -283,7 +286,7 @@ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Scope>>> = OnceLock::new();
|
|
|
283
286
|
/// What an open `with_backend` scope restores when it closes.
|
|
284
287
|
struct Scope {
|
|
285
288
|
/// The process that opened the scope.
|
|
286
|
-
|
|
289
|
+
owner: ProcessIdentity,
|
|
287
290
|
/// The backend that was the default before the push, kept to be put back
|
|
288
291
|
/// by a pop in the same process. `None` when there is no such backend of
|
|
289
292
|
/// this process's own: a forked child took it out to leak it, or the push
|
|
@@ -308,7 +311,7 @@ fn push_default_backend(
|
|
|
308
311
|
api_key: Option<String>,
|
|
309
312
|
profile: Option<String>,
|
|
310
313
|
) -> Result<u32, Error> {
|
|
311
|
-
let cur =
|
|
314
|
+
let cur = ProcessIdentity::current();
|
|
312
315
|
let selection = Selection::Explicit {
|
|
313
316
|
kind,
|
|
314
317
|
url,
|
|
@@ -332,7 +335,7 @@ fn push_default_backend(
|
|
|
332
335
|
lock(backend_scopes()).insert(
|
|
333
336
|
token,
|
|
334
337
|
Scope {
|
|
335
|
-
|
|
338
|
+
owner: cur,
|
|
336
339
|
previous,
|
|
337
340
|
selection: before,
|
|
338
341
|
},
|
|
@@ -352,7 +355,7 @@ fn push_default_backend(
|
|
|
352
355
|
/// is a state the caller can describe and leave with `set_default_backend`;
|
|
353
356
|
/// a token nobody can retry (`with_backend` never hands it out) would not be.
|
|
354
357
|
fn pop_default_backend(token: u32) -> Result<(), Error> {
|
|
355
|
-
let cur =
|
|
358
|
+
let cur = ProcessIdentity::current();
|
|
356
359
|
// (The registry lock is not held while an exception is built.)
|
|
357
360
|
let Some(scope) = lock(backend_scopes()).remove(&token) else {
|
|
358
361
|
return Err(error::base_error("unknown backend scope token"));
|
|
@@ -384,7 +387,7 @@ fn pop_default_backend(token: u32) -> Result<(), Error> {
|
|
|
384
387
|
|
|
385
388
|
match scope.previous {
|
|
386
389
|
// Opened and closed in the same process: put the saved backend back.
|
|
387
|
-
Some(previous) if scope.
|
|
390
|
+
Some(previous) if scope.owner == cur => microsandbox::set_default_backend(previous),
|
|
388
391
|
// No backend of this process's own was saved (see `Scope::previous`):
|
|
389
392
|
// restore one built here from how the saved one had been chosen. On
|
|
390
393
|
// failure the temporary backend stays installed (see above). The
|
|
@@ -393,8 +396,9 @@ fn pop_default_backend(token: u32) -> Result<(), Error> {
|
|
|
393
396
|
// forked child ever drops a backend it might have inherited. That
|
|
394
397
|
// leaks at most one backend per scope closed this way.
|
|
395
398
|
previous => {
|
|
396
|
-
// `None`
|
|
397
|
-
//
|
|
399
|
+
// Always `None` here: a process that is not the scope's opener
|
|
400
|
+
// took the saved backend out when it adopted (`adopt_backend`).
|
|
401
|
+
// An inherited `Arc` would be leaked either way.
|
|
398
402
|
std::mem::forget(previous);
|
|
399
403
|
let fresh = scope.selection.build().map_err(error::to_ruby)?;
|
|
400
404
|
std::mem::forget(microsandbox::swap_default_backend(fresh));
|