From 8af7268c2649de65cdcb8a571df45f440cb9094e Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 1 Sep 2026 11:31:24 +0200 Subject: [PATCH] Revert the capability set, and say what isolated mode bounds Enforcing what a child may reach from inside CPython does not work: an audit hook never sees a C extension, and the open event does not say which directory a relative path resolves against, so the containment could be walked around with documented calls. The tree returns to 5.0.0 and the guides say plainly that the child holds every authority the node's user holds, with the operating system mechanisms that do bound it. --- CHANGELOG.md | 25 - README.md | 11 +- docs/capabilities.md | 238 -------- docs/code-map.md | 2 - docs/decisions/0009-child-capabilities.md | 92 ---- docs/decisions/overview.md | 1 - docs/isolated.md | 25 +- docs/security.md | 36 +- priv/_erlang_impl/_caps.py | 571 -------------------- priv/_erlang_impl/_isolated.py | 11 +- priv/py_isolated_child.py | 31 +- priv/tests/test_caps.py | 253 --------- rebar.config | 6 +- src/erlang_python.app.src | 2 +- src/py_caps.erl | 263 --------- src/py_context.erl | 27 +- src/py_isolated.erl | 64 +-- test/coverage_audit.md | 1 - test/py_isolated_caps_SUITE.erl | 628 ---------------------- test/py_test_caps.py | 171 ------ 20 files changed, 49 insertions(+), 2409 deletions(-) delete mode 100644 docs/capabilities.md delete mode 100644 docs/decisions/0009-child-capabilities.md delete mode 100644 priv/_erlang_impl/_caps.py delete mode 100644 priv/tests/test_caps.py delete mode 100644 src/py_caps.erl delete mode 100644 test/py_isolated_caps_SUITE.erl delete mode 100644 test/py_test_caps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d70309d..1514ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,30 +1,5 @@ # Changelog -## 5.1.0 (unreleased) - -### Added - -- **Capabilities for isolated children** - `py_context:new(#{mode => isolated, - caps => ...})` names the directories, environment variables and network - addresses a child may reach; anything not named is refused. Leaving a key - out grants none of it, and omitting `caps` leaves existing behaviour - unchanged. Paths are resolved a component at a time with `openat` and - `O_NOFOLLOW` from the descriptor of the grant, so `..`, absolute paths, - symlinks out of a grant and symlinked directory prefixes are all refused, - and refusals are `PermissionError` rather than `FileNotFoundError` so they - disclose nothing about what exists outside. Network rules name addresses - and never host names, resolution is its own capability covering every - resolver, and binding is checked against `listen` rather than `connect`. - Process creation, `ctypes`, signals to another process and Unix-socket - addresses are refused outright. The model and its vocabulary come from - erlang_wasm's WASI implementation. - - This is a cooperative policy over Python and not a boundary: it is built - on a CPython audit hook, so it binds Python and not a C extension, and it - covers only what CPython announces. `docs/capabilities.md` says what holds - and what does not. Shared memory and capability sets do not combine yet, - because a region reaches the child as a path. - ## 5.0.0 (2026-08-29) ### Added diff --git a/README.md b/README.md index 277e715..ca06e00 100644 --- a/README.md +++ b/README.md @@ -622,21 +622,14 @@ When creating Python contexts, you can choose the execution mode: %% segfault only takes the child down, rlimits bound memory and CPU. {ok, Ctx} = py_context:new(#{mode => isolated, kill_after => 1000, rlimits => #{as => 512 * 1024 * 1024}}). - -%% Name what the child may reach, and it reaches nothing else. -{ok, Ctx} = py_context:new(#{mode => isolated, - caps => #{dirs => [{"/srv/models", read}], - net => #{connect => [{tcp, <<"10.0.0.0/8">>, 5432}]}}}). ``` **Isolated mode** is the only mode with a hard bound: `py_context:interrupt/1` stops a blocking C call, and `SIGKILL` is the backstop. It costs a process per context (about 16 MB and 40 ms to start) and roughly twice the call latency. Bulk data crosses through shared memory (`py_shm`, with the optional -[iommap](https://hex.pm/packages/iommap) dependency), and the `caps` option -names the files, addresses and environment the child may reach, as a -cooperative policy over Python rather than a kernel boundary. See -[Isolated Contexts](docs/isolated.md) and [Capabilities](docs/capabilities.md). +[iommap](https://hex.pm/packages/iommap) dependency). See +[Isolated Contexts](docs/isolated.md). **Worker mode is recommended** because it works with any Python version and automatically benefits from free-threaded Python (3.13t+) when available. Each context owns a dedicated pthread, providing stable thread affinity for libraries with thread-local state (numpy, torch, tensorflow). diff --git a/docs/capabilities.md b/docs/capabilities.md deleted file mode 100644 index c1bb0a9..0000000 --- a/docs/capabilities.md +++ /dev/null @@ -1,238 +0,0 @@ -# Capabilities - -This guide covers `caps`, the option that says what an isolated child may -reach: which directories, which environment variables, which addresses. -Python that asks for anything else is refused. It is the WASI model, and -the vocabulary is the same as -[erlang_wasm](https://github.com/benoitc/erlang_wasm)'s, so a grant means -the same thing in both. - -**Read this before you rely on it.** `caps` is a cooperative policy over -Python, not a boundary. It stops code that is not trying to get out, and it -makes what a job may touch explicit and reviewable. It does not stop code -that is trying to get out: a C extension calling `open(2)` never reaches -the audit hook it is built on. Use it for code you partly trust; use the -process boundary in [Isolated Contexts](isolated.md) for the rest, and see -[what holds and what does not](#what-holds-and-what-does-not) for the -detail. - -Read [Isolated Contexts](isolated.md) first: `caps` only applies there. - -## Grant what it needs - -```erlang -{ok, Ctx} = py_context:new(#{ - mode => isolated, - caps => #{ - dirs => [{"/srv/models", read}, - {"/var/data/job42", write}], - env => #{<<"MODEL_DIR">> => <<"/srv/models">>}, - net => #{connect => [{tcp, <<"10.0.0.0/8">>, {5432, 5432}}], - resolve => deny} - }}), -{ok, _} = py_context:call(Ctx, scorer, run, [<<"job42">>]). -``` - -Inside the child, ordinary Python works inside the grants and fails outside -them: - -```python -open('/srv/models/weights.bin', 'rb') # granted -open('/var/data/job42/out.csv', 'w') # granted -open('/etc/passwd') # PermissionError -open('/srv/models/w', 'w') # PermissionError: read grant -``` - -Leave a key out and there is none of it. `caps => #{}` grants nothing but the -interpreter's own files, and no `caps` key at all is the behaviour you have -today: the child holds every authority the user running the node holds. - -## What each key grants - -| key | grants | leaving it out means | -| --- | --- | --- | -| `dirs` | directories, `read` or `write` | no filesystem beyond the interpreter's own | -| `env` | environment variables | zero variables, **not** the node's | -| `net` | sockets, to the addresses you name | no network at all | - -`read` covers opening, reading and listing. `write` adds creating, renaming, -unlinking and truncating. Rights apply to everything below the directory, so -a `read` grant yields no writable file however the code opens it. - -Always granted, because nothing works otherwise: the interpreter's own -`sys.path`, `sys.prefix` and `sys.base_prefix` for reading, so imports work, -and `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/urandom`. WASI preopens -its sysroot for the same reason. Directories you list in the `paths` option -are granted for reading too, since that option tells the child to import -from them. - -The `env` option and `caps` cannot be used together. The option adds -variables and a grant says what the whole environment is, so taking both -would let the option quietly win; `caps.env` is the one that names an -environment, and using both is `{error, {bad_caps, -env_option_conflicts_with_caps_env}}`. - -## Name a network - -```erlang -net => #{connect => [{tcp, <<"10.0.0.0/8">>, {8000, 8099}}], - listen => [{tcp, <<"127.0.0.1">>, 8080}], - resolve => allow} -``` - -A rule is `{Proto, Addr, Port}`. `Proto` is `tcp` or `udp`, `Addr` is an -address tuple, a binary address or a binary CIDR, and `Port` is an integer, a -`{Lo, Hi}` range, or `any`. - -Four things to know before writing your first grant: - -- **`connect` and `listen` are separate**, and neither implies the other. - Binding claims a local address, which is what `listen` grants, so code - wanting a particular source port needs a `listen` rule for it. -- **You name addresses, never names.** There is no rule that says - `example.com`: a name would have to be resolved to be checked and resolved - again to be used, and the two answers can differ. `resolve` is its own - capability, off unless granted, and what it returns carries no authority. - Code may learn an address it cannot reach, and the connect is refused then. -- **`::ffff:127.0.0.1` is `127.0.0.1`.** IPv4-mapped addresses are folded - before matching, so the mapped notation cannot walk past an IPv4 rule. -- **Nothing is denied implicitly.** `<<"0.0.0.0/0">>` really does include - link-local and cloud metadata addresses. Name what you mean. - -A socket Erlang opened and handed over with `py_context:pass_fd/2` needs no -rule: the child was given the descriptor, and the descriptor is the -capability. That is how you serve on a port under a capability set. - -```erlang -{ok, LSock} = gen_tcp:listen(8080, [binary, {active, false}]), -{ok, Fd} = inet:getfd(LSock), -{ok, ChildFd} = py_context:pass_fd(Ctx, Fd), -ok = py_context:start_loop(Ctx), -{ok, _} = py_context:submit_await(Ctx, myapp, serve, [ChildFd]). -``` - -## Shared memory does not combine with this yet - -A `py_shm` region reaches the child as a path, so under a capability set it -is refused like any other ungranted path. Granting it would mean granting -the directory the node keeps every region in, which hands over every -region, and an open grant cannot prevent truncation anyway because -`file.truncate()` announces nothing. A truncated region is a `SIGBUS` in -the VM that mapped it, so the half-measure is worse than the refusal. - -The fix is to pass the region's descriptor rather than its name, with -`memfd_create` and `F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_SEAL` on Linux, -which keeps a writable mapping while making the object unresizable, and -cooperative-only handling where sealing does not exist. Until that lands, -use shared memory or a capability set, not both. - -## What is refused - -Every route below has its own test case in `py_isolated_caps_SUITE`. All are -refused with a `PermissionError` and never a `FileNotFoundError`, so the -error cannot be used to find out what exists outside a grant. - -| the code asks for | it gets | -| --- | --- | -| `note.txt`, `./note.txt`, `sub/deep.txt` | opened | -| `sub/../note.txt` | opened: it never leaves the grant | -| `../secret/key.txt` | refused | -| `/etc/passwd` | refused | -| `escape.txt`, a symlink out of the grant | refused | -| `outdir/key.txt`, through a symlinked directory | refused | -| `sub/../../secret/key.txt` | refused | -| a symlink cycle | refused | -| `missing.txt` inside a grant | `FileNotFoundError` | -| `subprocess.run`, `os.fork`, `os.exec*` | refused | -| `ctypes.CDLL` | refused | -| `os.kill` at another process, `os.killpg` | refused | -| `socket.gethostbyname` and every other resolver | refused unless `resolve` | -| `os.mkfifo`, `os.mknod` | not there: see below | -| a Unix-socket address, connect or bind | refused | - -Signalling is refused because the child usually shares the node's user and -its parent is the BEAM, so an unchecked `os.kill` is a way to take the node -down. Signalling itself is allowed. Every resolver is gated, not only -`getaddrinfo`: a name lookup is a message to whoever answers it, so gating -one of them would leave the rest as a way out. A Unix-socket address is -refused rather than checked as a file, because reaching one is talking to -whatever is behind it, which a directory grant says nothing about; a -descriptor Erlang passed over with `py_context:pass_fd/2` is unaffected. - -Subprocesses are refused outright: a capability set names what may be -reached, and another process is not something you granted. `ctypes` is -refused because it reaches libc directly, which would make every rule above -advisory. A library that needs `ctypes` cannot run under a capability set. - -## What holds and what does not - -Enforcement is a CPython audit hook, so the whole of what it can see is -what CPython announces, and everything below follows from that. - -**What holds.** Python code that asks for a path, an address or a process -outside the grants is refused, whether it asks through `open`, `os.open`, -`pathlib`, numpy or any other library, because the event is raised by the -interpreter rather than by the caller. Path containment is resolved by the -kernel one component at a time, so `..`, absolute paths, symlinks out of a -grant and symlinked directory prefixes are all refused rather than -lexically guessed at. Nothing on the decision path is reachable by name: -the grants, the tables and even the `os` functions the check uses are bound -into the hook's closure when it is installed, so assigning to this module -changes nothing. - -**What does not hold.** - -- A C extension calling `open(2)` or `connect(2)` never reaches an audit - hook. Neither does `file.truncate()` or `mmap.resize()`, which CPython - does not announce, so a writable descriptor can always shorten its own - file. That is part of what a `write` grant grants. -- A thread that replaces a path between the check and the kernel's own - resolution is not stopped. Do not point a `write` grant at a directory - another party writes to concurrently. -- `os.stat`, `os.access` and the rest of the calls that observe without - reaching are left alone, so what exists outside a grant stays visible - even though reading it does not. -- Closure state is a bar, not a wall. Python exposes its own object graph, - and code that goes looking can reach a hook's cells. - -**What would make it hold.** A kernel. Landlock on Linux takes the same -grant table and enforces it below the interpreter, which is the point at -which a C extension stops being an exception; moving the state and the hook -into the NIF would take the rest. Neither is here yet. Until then, the -boundary you have is the process: `rlimits`, `kill_after`, and the -supervision in [Isolated Contexts](isolated.md). - -## Cost - -The check is an audit hook, so it runs on every open, and paths are resolved -one component at a time against the descriptor of the grant. Measured on -macOS with Python 3.14, an open inside a grant costs about 11 microseconds -more than an unguarded one, and the cost grows with the depth of the path -below its grant. Grant close to what the code reads: `/srv/models` rather -than `/`. - -Nothing else changes. Calls, results, shared memory and interrupts are what -they were. - -## Check what a child got - -```erlang -{ok, Info} = py_context:child_info(Ctx), -maps:get(caps, Info). -``` - -```python -import erlang -erlang.caps() # None when no capability set was given -``` - -Both report the grants as the child holds them, including the automatic -ones, and `strict_paths` tells you whether the platform resolved paths -component by component or fell back to a lexical check. - -## See also - -- [Isolated Contexts](isolated.md) for the process boundary itself -- [Security](security.md) for what the embedded modes do instead -- [decision 0009](decisions/0009-child-capabilities.md) for why it is shaped - this way diff --git a/docs/code-map.md b/docs/code-map.md index fefea58..9da9c9b 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -29,7 +29,6 @@ exercised by suites). Guides are in `docs/`, suites in `test/`. Start with | `py_channel`, `py_byte_channel` | Term and byte queues between Erlang and Python coroutines (NIF resources) | live | channel | `py_channel_SUITE`, `py_byte_channel_SUITE` | | `py_buffer` | Native streaming input buffer; shared variant delegates to `py_shm` | live | buffer, isolated | `py_buffer_SUITE`, `py_isolated_buffer_SUITE` | | `py_shm` | Shared memory regions over iommap and the ring behind shared buffers | live | isolated | `py_isolated_shm_SUITE` | -| `py_caps` | The `caps' option: what an isolated child may reach, and its wire form | live | capabilities | `py_isolated_caps_SUITE` | | `py_import` | Registry of imports and `sys.path` entries applied to every interpreter | live | imports | `py_import_SUITE` | | `py_preload` | Code run once per interpreter at start | live | preload | `py_preload_SUITE` | | `py_state` | Shared key/value store visible from Python as `erlang.state_get/set/delete/keys` | live | README (shared state) | `py_state_SUITE` | @@ -81,7 +80,6 @@ loop, channels and servers. | `_erlang_impl/_etf.py` | Pure-Python ETF codec with the `py_convert.c` mapping | isolated child | | `_erlang_impl/_isolated.py` | Child runtime: socket frames, reader thread, re-entrant main loop, interrupt signal, asyncio loop, the `erlang` shim | isolated child | | `_erlang_impl/_shm.py` | `SharedMemory` and `SharedBuffer` wrappers over mmap | all | -| `_erlang_impl/_caps.py` | Capability enforcement in the child: path containment, address matching, the audit hook | isolated child | | `py_isolated_child.py` | Child launcher: rlimits, parent-death signal, cgroup join, connect | isolated child | | `test_erlang_loop.py`, `test_async_task.py`, `test_channel_ref.py`, `tests/` | Python-side tests of the loop, tasks and channels | test | diff --git a/docs/decisions/0009-child-capabilities.md b/docs/decisions/0009-child-capabilities.md deleted file mode 100644 index 682e024..0000000 --- a/docs/decisions/0009-child-capabilities.md +++ /dev/null @@ -1,92 +0,0 @@ -# 0009: An isolated child reaches only what it was granted - -Since 5.1.0. Code: `src/py_caps.erl`, `priv/_erlang_impl/_caps.py`, the -prologue of `priv/py_isolated_child.py`. - -## Situation - -Isolated mode bounded what Python could *consume*: memory, CPU, time, and a -crash. It bounded nothing it could *reach*. The child ran as the node's user -with the node's environment, could read and write every file that user -could, dial anywhere, spawn processes, and truncate the shared memory -regions it was handed, which turns the mapping the VM holds into a `SIGBUS`. -The audit hook the embedded modes install was never installed there. - -The first sketch was a deny list of dangerous operations. That is the wrong -shape: it enumerates what to stop, so it is wrong the moment something new -appears, and it says nothing about what a job is supposed to touch. - -## Decision - -Grant capabilities instead. `py_context:new(#{mode => isolated, caps => ...})` -names directories with an access level, environment variables, and network -rules; nothing else is reachable. No `caps` key leaves existing behaviour -alone, so this is additive. - -The model, the option shape, the refusal semantics and the test list are -taken from `erlang_wasm`'s WASI preview 1 implementation rather than -invented: `{Proto, Addr, Port}` rules, addresses and never host names, -resolution as its own capability, binding checked against `listen` and not -`connect`, IPv4-mapped folding, a malformed rule raising where the grant is -written. A grant means the same thing in both projects. - -Enforcement is an audit hook in the child, installed last in the prologue so -the runtime's own imports are outside the grants and everything after them -is inside. Paths are resolved a component at a time with `openat` and -`O_NOFOLLOW` from the descriptor of the grant, following symlinks by hand: -that is erlang_wasm's `native` backend, which needed a C NIF there because -Erlang has no `openat`, and needs none here because Python has one. - -Not chosen: routing every open through Erlang so `wasi_fs` could enforce it -directly. It would share one implementation and close the check-to-use -window, but it makes an `open` a socket round trip from arbitrary code, -including the child's own reader thread, and the deadlock surface is not -worth it at 25 microseconds a call. - -## What this is not - -Three review rounds all found the same shape of defect: a way for Python to -step around a check written in Python. Each was real and each was closed, -but the pattern is the point. An audit hook is a cooperative policy, and -the honest split is: - -* `caps` is for code you partly trust. It stops mistakes and casual misuse, - and it makes what a job may touch reviewable. -* The process, its rlimits and `kill_after` are what hold against code that - is trying to get out. -* Kernel enforcement (Landlock, seccomp, or the state and hook moved into - the NIF) is the point at which `caps` may be described as protection - against adversarial code. It is not there yet, and the guide says so - rather than implying otherwise. - -## Consequences - -- It is a policy over Python, not a boundary. A C extension calling - `open(2)`, or a thread swapping a path between the check and the kernel's - resolution, is not stopped. The guide says so in those words. -- The grants have to live in the hook's closure rather than in module - state, and nothing inside the interpreter may widen them. An earlier - version kept them in a module attribute and let `_shm` add region paths: - both were levers any Python could pull. Regions are now granted from - Erlang, as the directory holding them, opened and nothing more. -- Enforcement can only cover what CPython announces. Calls that create - something and raise no audit event are removed from `os` and `posix` - instead, and the ones that only observe are documented as visible. -- Nothing on the decision path may be reachable by name, including the - re-entrancy guard, the event tables and the `os` functions the check - itself calls. The guard is set around the containment walk alone, since a - wider one would leave a user `__fspath__` running with enforcement off. -- Shared memory does not combine with a capability set. A region arrives as - a path, granting the directory would hand over every region the node - owns, and an open-only grant cannot stop truncation because - `file.truncate()` announces nothing. Passing the descriptor, sealed on - Linux, is the way to make it work. -- `ctypes` must be refused, or every rule is advisory. Libraries that need - it cannot run under a capability set. -- The interpreter's own `sys.path` is granted automatically, or nothing - imports. A capability set therefore always grants reading the standard - library, as WASI's preopened sysroot does. -- An open inside a grant costs about 11 microseconds more, growing with path - depth, so grants should sit close to what is read. -- Landlock on Linux consumes the same table and would make it a boundary. - The table is shaped for that. diff --git a/docs/decisions/overview.md b/docs/decisions/overview.md index 9cc5e18..0d1f7eb 100644 --- a/docs/decisions/overview.md +++ b/docs/decisions/overview.md @@ -16,4 +16,3 @@ what was decided, what it costs, and where the code is. | [0006](0006-shared-memory-over-iommap.md) | Bulk data through iommap regions, handles as plain tuples | 5.0.0 | | [0007](0007-remove-legacy-execution-paths.md) | One execution path per mode; the legacy API is removed | 5.0.0 | | [0008](0008-pipe-io-rules.md) | Pipe I/O is non-blocking, deadlined and waited with poll | 3.1.0, 5.0.0 | -| [0009](0009-child-capabilities.md) | An isolated child reaches only what it was granted | 5.1.0 | diff --git a/docs/isolated.md b/docs/isolated.md index 87ac086..9705a70 100644 --- a/docs/isolated.md +++ b/docs/isolated.md @@ -312,19 +312,24 @@ file on purpose; sealing and syscall filtering are separate hardening work. event worker to step an idle loop). - `erlang.call` from inside a coroutine blocks the loop, as in the embedded modes; use `erlang.async_call`. -- Without `caps` the child holds every authority the user running the node - holds: it reads and writes what that user can, dials anywhere and can - spawn processes. - The child decodes terms with the same rules as the NIF, so atoms sent from Python are created in the VM's atom table. Do not let untrusted code mint unbounded distinct atoms. -- No syscall filtering: process isolation plus rlimits is the boundary. What - the child may *reach* (files, addresses, environment) is named with the - `caps` option, which is a cooperative policy over Python rather than a - kernel boundary: see [capabilities](capabilities.md). -- Shared memory and `caps` do not combine: a region reaches the child as a - path, and granting it would grant every region the node owns. A seccomp (Linux) or Capsicum (FreeBSD) - sandbox is a separate hardening step. +- **The child holds every authority the user running the node holds.** It + reads and writes every file that user can, opens any network connection, + spawns processes, and reads the environment the node was started with, + including any credentials in it. Isolated mode bounds what Python may + *consume*, not what it may *reach*. +- No syscall filtering. Process isolation plus rlimits is the boundary, and + it is a resilience boundary: a crash, a runaway loop or a memory blowup is + contained, a deliberate reach for something is not. Confining what the + child may reach needs a kernel sandbox (Landlock on Linux, Seatbelt on + macOS), which is not here; enforcing it inside CPython was tried and does + not work, because an audit hook cannot see a C extension calling `open(2)` + and cannot tell which directory a relative path resolves against. If you + need that confinement today, use what the operating system already gives + you around the whole node: a container, a jail, or a separate user with + file permissions to match. - Each call copies its arguments and result through the socket: a 1 MB binary round-trips in about 1.3 ms, 16 MB in about 27 ms (worker mode: 0.2 ms and 3 ms). For bulk data use shared memory (below). diff --git a/docs/security.md b/docs/security.md index 640c77f..858b718 100644 --- a/docs/security.md +++ b/docs/security.md @@ -160,21 +160,27 @@ child process: A crash kills only the child, `py_context:kill/1` is total, and rlimits or cgroups bound resources. See [Isolated Contexts](isolated.md). -That bounds what Python may consume. What it may *reach* is named with the -`caps` option: - -```erlang -{ok, Ctx} = py_context:new(#{mode => isolated, - caps => #{dirs => [{"/srv/models", read}], - net => #{connect => [{tcp, <<"10.0.0.0/8">>, 5432}]}}}). -``` - -Anything not named is refused. That is a cooperative policy over Python: it -binds Python code, not a C extension, because it is built on an audit hook. -[Capabilities](capabilities.md) says what holds and what does not. The child -is not sandboxed at the syscall level; that is a separate hardening step, -and the point at which capability sets would bind an adversary rather than -a mistake. +That is a boundary against Python *failing*, not against Python *reaching*. +The child runs as the same user as the node, so it can read and write +whatever that user can, open any connection, spawn processes, and read the +environment the node was started with. Nothing in this library confines it, +and confining it from inside the interpreter does not work: an audit hook +never sees a C extension calling `open(2)`, and the audit event for `open` +does not say which directory a relative path is resolved against, so a +check written in Python can be walked around with documented calls. + +If you need to bound what Python may reach, use the mechanisms the +operating system already has, around the node rather than inside it: + +- a container or jail, with the filesystem and network the job should see; +- a separate user, with file permissions to match, and `py_context:new/1` + started from a node running as that user; +- on Linux, a systemd unit with `ProtectSystem`, `ReadWritePaths` and + `IPAddressDeny`, which express the same intent and are enforced by the + kernel. + +Kernel sandboxing per context (Landlock, Seatbelt) would let this library +express it directly; it is not implemented. ## Signal Handling Note diff --git a/priv/_erlang_impl/_caps.py b/priv/_erlang_impl/_caps.py deleted file mode 100644 index 9032fe2..0000000 --- a/priv/_erlang_impl/_caps.py +++ /dev/null @@ -1,571 +0,0 @@ -# Copyright 2026 Benoit Chesneau -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""What an isolated child may reach, enforced over Python. - -Erlang names the directories, environment variables and addresses this -process may reach (`py_caps.erl`); this module refuses the rest. It is -installed once, in the child's prologue, before any user code runs. - -**This is a cooperative policy, not a boundary.** The difference decides -what you may use it for: - -* It stops code that is not trying to get out. Reading the wrong dataset, - writing outside a job's directory, calling home: those stop, and what a - job may touch becomes something you can read off the context options. -* It does not stop code that is trying to get out. A C extension calling - `open(2)` never reaches an audit hook. Neither does `file.truncate()`, - which CPython does not announce. And the grants live in a closure rather - than in a module attribute, which removes the one-line way to switch them - off but not the last one: Python exposes its own object graph, so code - that goes looking can reach a hook's cells. - -Hard isolation is a kernel's job and is not here yet. Landlock on Linux -takes the same grant table; moving the state and the hook into the NIF -would take the rest. Until then, treat `caps` as a policy for partially -trusted code, and the process boundary in `docs/isolated.md` as the thing -that holds against the rest. - -The audit surface, which is the whole of what is enforced: - -* **Refused by rule**: `open`, `os.listdir`, `os.scandir`, `os.mkdir`, - `os.rmdir`, `os.remove`, `os.rename`, `os.link`, `os.symlink`, - `os.truncate`, `os.chmod`, `os.chown`, `os.utime`, the `shutil.*` events, - `socket.connect`, `socket.bind`, `socket.sendto`, and every resolver. -* **Refused outright**: process creation, `ctypes`, signals to anything but - this process, and Unix-socket addresses. -* **Ignored, deliberately**: `os.stat`, `os.access`, `os.statvfs`, - `os.chdir` and the other calls that observe without reaching. What exists - outside a grant stays visible; reading it does not. -* **Removed, because CPython announces nothing**: `os.mkfifo` and - `os.mknod`, which create. `file.truncate()` and `mmap.resize()` announce - nothing either and cannot be removed, so a writable descriptor can always - shorten its own file. That is part of what a `write` grant grants, and it - is why there is no grant that means "open but do not resize". - -Path containment is erlang_wasm's native backend (`c_src/wasi_file_nif.c`), -which needs no C here because Python has `openat`: walk a component at a -time with ``O_NOFOLLOW`` from the descriptor of the grant, follow a symlink -by hand so it is worth what the same text written out is worth, and count -depth so ``..`` moves inside a grant but not out of it. - -Refusals are `PermissionError`, never `FileNotFoundError`, so a refusal -says nothing about what exists outside a grant. -""" - -import errno -import ipaddress -import os -import socket -import sys -import threading - -__all__ = ['install', 'installed', 'grants', 'CapabilityError'] - -# Eight, as Linux allows per path. It has to be a constant a cycle cannot -# outrun; a self-referential link is otherwise not an error but a hang. -_MAX_SYMLINKS = 8 - -_READ, _WRITE = 'read', 'write' - -# Devices that carry nothing about the host and whose absence breaks code in -# ways that are hard to read. Granted for reading with any capability set. -_ALWAYS_READ = ('/dev/null', '/dev/urandom', '/dev/random', '/dev/zero') - -# Process creation, always refused: a capability set names what may be -# reached, and another process is not something it was granted. -_SUBPROCESS_EVENTS = frozenset({ - 'subprocess.Popen', 'os.system', 'os.popen', 'os.fork', 'os.forkpty', - 'os.posix_spawn', 'os.posix_spawnp', -}) -_EXEC_PREFIXES = ('os.exec', 'os.spawn') - -# Signalling, refused except towards this process. The child usually shares -# the node's user and its parent is the BEAM, so an unchecked os.kill is a -# way to take the node down. -_SIGNAL_EVENTS = frozenset({'os.kill', 'os.killpg'}) - -# Resolution, granted by `resolve`. Every one of these reaches a resolver, -# so gating only getaddrinfo would leave the rest as a way out. -_RESOLVE_EVENTS = frozenset({ - 'socket.getaddrinfo', 'socket.gethostbyname', 'socket.gethostbyaddr', - 'socket.getnameinfo', 'socket.getservbyname', 'socket.gethostname', -}) - -# ctypes reaches libc directly, so leaving it open would make every rule -# here advisory. A library that needs it cannot run under a capability set. -_CTYPES_PREFIX = 'ctypes.' - -# Calls that create something and announce nothing, so they are taken away -# rather than refused. -_UNAUDITED_CREATORS = ('mkfifo', 'mknod') - -# Audit events that name a path, and what they need for it. -_PATH_EVENTS = { - 'os.listdir': _READ, - 'os.scandir': _READ, - 'os.mkdir': _WRITE, - 'os.rmdir': _WRITE, - 'os.remove': _WRITE, - 'os.rename': _WRITE, - 'os.link': _WRITE, - 'os.symlink': _WRITE, - 'os.truncate': _WRITE, - 'os.chmod': _WRITE, - 'os.chown': _WRITE, - 'os.utime': _WRITE, - 'shutil.copyfile': _WRITE, - 'shutil.copymode': _WRITE, - 'shutil.copystat': _WRITE, - 'shutil.copytree': _WRITE, - 'shutil.move': _WRITE, - 'shutil.rmtree': _WRITE, - 'shutil.unpack_archive': _WRITE, -} - -# Operations that act on a name and not on what it points at, so the last -# component is not followed: removing a symlink that leads out of a grant -# removes something inside the grant. -_NAME_EVENTS = frozenset({ - 'os.remove', 'os.rename', 'os.symlink', 'os.link', 'os.rmdir', 'os.mkdir', -}) - -# Events that name two paths; both ends are checked. -_TWO_PATH_EVENTS = frozenset({ - 'os.rename', 'os.link', 'os.symlink', 'shutil.copyfile', 'shutil.copymode', - 'shutil.copystat', 'shutil.copytree', 'shutil.move', -}) - -# A summary of what was granted, for `grants()`. It gates nothing: the -# grants themselves are reachable only from the hook's closure. -_summary = None - - -class CapabilityError(PermissionError): - """Raised for anything a capability set does not grant. - - A `PermissionError`, so code that already handles one keeps working, and - never a `FileNotFoundError`: whether a path outside a grant exists is - not something a refusal should disclose. - """ - - -class _Grant: - """One granted directory, held open. - - Opened once and kept: naming the directory by path on every check would - leave it to be resolved again each time, so replacing it would move the - grant. Anchored to the descriptor, a swapped *child* is what gets - refused. - - Both the path as granted and its resolved form are prefixes, because a - grant is often reached through a symlink (`/tmp` is `/private/tmp` on - macOS) and code inside the child will name it either way. - """ - - __slots__ = ('path', 'access', 'fd', 'prefixes') - - def __init__(self, path, access): - self.path = path - self.access = access - self.fd = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0)) - real = os.path.realpath(path) - self.prefixes = (path,) if real == path else (path, real) - - def writable(self): - return self.access == _WRITE - - def remainder(self, path): - """The part of `path` below this grant, or None if it is not under it.""" - for prefix in self.prefixes: - if path == prefix: - return '' - if path.startswith(prefix.rstrip('/') + '/'): - return path[len(prefix.rstrip('/')) + 1:] - return None - - -class _State: - __slots__ = ('dirs', 'files', 'net', 'lexical') - - def __init__(self): - self.dirs = [] - # Exact paths that may be opened for reading: the devices above. - self.files = {} - self.net = None - self.lexical = False - - -class _Enforcer: - """What `_make_enforcer` returns: the hook, and its parts for tests.""" - - __slots__ = ('hook', 'walk', 'contained', 'check_path', 'writes') - - def __init__(self, **parts): - for name, part in parts.items(): - setattr(self, name, part) - - -def _make_enforcer(st): - """Build the audit hook over `st`. - - Everything on the decision path is bound here rather than looked up when - the hook runs, because a name resolved at call time is a name any Python - in this process can rebind: `_caps._writes = lambda *_: False` would - otherwise turn every open into a read. That includes `os` itself, so the - primitives are bound one by one, and the walk lives here rather than at - module level so no shared function object is left behind whose defaults - could be rewritten. - """ - # syscalls and constants, bound once - _open, _close, _readlink = os.open, os.close, os.readlink - _getcwd, _getpid = os.getcwd, os.getpid - _fspath, _fsdecode = os.fspath, os.fsdecode - _normpath, _abspath = os.path.normpath, os.path.abspath - _O_RDONLY, _O_NOFOLLOW = os.O_RDONLY, os.O_NOFOLLOW - _O_DIRECTORY = getattr(os, 'O_DIRECTORY', 0) - _O_WRITES = (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC - | os.O_APPEND) - # A symlink met with O_NOFOLLOW: ELOOP on Linux and macOS, EMLINK on - # FreeBSD. - _ELOOP, _EMLINK = errno.ELOOP, errno.EMLINK - _ip_address = ipaddress.ip_address - _SOCK_DGRAM = socket.SOCK_DGRAM - _error = CapabilityError - READ, WRITE = _READ, _WRITE - - # the tables, copied so rebinding a module attribute changes nothing - path_events = dict(_PATH_EVENTS) - name_events = frozenset(_NAME_EVENTS) - two_path_events = frozenset(_TWO_PATH_EVENTS) - subprocess_events = frozenset(_SUBPROCESS_EVENTS) - exec_prefixes = tuple(_EXEC_PREFIXES) - ctypes_prefix = _CTYPES_PREFIX - signal_events = frozenset(_SIGNAL_EVENTS) - resolve_events = frozenset(_RESOLVE_EVENTS) - dirs, files, net, lexical = st.dirs, st.files, st.net, st.lexical - max_links = _MAX_SYMLINKS - - # The re-entrancy guard, created here so there is no module attribute to - # assign to. It is set around the walk and nothing else: the walk calls - # only `open`, `close` and `readlink` on its own account, so no user - # code can run while enforcement is off. - busy = threading.local() - - def walk(grant, rel, follow_last): - """Resolve `rel` beneath `grant`, a component at a time. - - Returns `(dirfd, component, owned)`; the caller closes `dirfd` when - `owned`. Raises `CapabilityError` if the path leaves the grant. - """ - dirfd, owned = grant.fd, False - depth = links = 0 - pending = [p for p in rel.split('/') if p not in ('', '.')] - busy.on = True - try: - while pending: - comp = pending.pop(0) - last = not pending - if comp == '..': - if depth == 0: - raise _error('path leaves the grant: %s' % rel) - depth -= 1 - nxt = _open('..', _O_RDONLY | _O_DIRECTORY, dir_fd=dirfd) - if owned: - _close(dirfd) - dirfd, owned = nxt, True - continue - if last and not follow_last: - return dirfd, comp, owned - try: - nxt = _open(comp, _O_RDONLY | _O_NOFOLLOW, dir_fd=dirfd) - except OSError as exc: - if exc.errno not in (_ELOOP, _EMLINK): - if last: - # Not there. That is not a containment answer; - # let the caller's own call raise its own error. - return dirfd, comp, owned - raise - if links >= max_links: - raise _error('too many symlinks: %s' % rel) from None - links += 1 - target = _readlink(comp, dir_fd=dirfd) - if target.startswith('/'): - # Refused rather than reinterpreted: resolving it - # against the grant would silently mean something - # other than what it says. - raise _error( - 'symlink leaves the grant: %s' % rel) from None - pending = [p for p in target.split('/') - if p not in ('', '.')] + pending - continue - if last: - _close(nxt) - return dirfd, comp, owned - if owned: - _close(dirfd) - dirfd, owned = nxt, True - depth += 1 - return dirfd, '.', owned - except BaseException: - if owned: - _close(dirfd) - raise - finally: - busy.on = False - - def contained(grant, path, need, follow): - """Is `path` inside `grant`, with `need` access? - - `path` keeps its `..` deliberately: collapsing them first is what - makes a check disagree with the kernel, because `link/..` is the - directory the link points into and not the one the link sits in. - """ - if need == WRITE and not grant.writable(): - return False - rel = grant.remainder(path) - if rel is None: - return False - if lexical: - depth = 0 - for comp in rel.split('/'): - if comp in ('', '.'): - continue - depth += -1 if comp == '..' else 1 - if depth < 0: - return False - return True - try: - dirfd, _comp, owned = walk(grant, rel, follow_last=follow) - except _error: - return False - except OSError: - # A component that is not there is not a containment answer: the - # path was inside the grant, it simply does not exist. - return True - if owned: - _close(dirfd) - return True - - def check_path(path, need, event, follow=True, opening=False): - # Conversion first and unguarded, because `__fspath__` is user code - # and has to run with the hook live, so its own opens are checked. - if not isinstance(path, (str, bytes)): - if isinstance(path, int): - # A descriptor. Reading through one is already granted, - # since opening it was checked; changing what it names is - # not, because a descriptor cannot be mapped back to a path - # portably enough to check. - if need == WRITE: - raise _error( - '%s: a capability set grants no change through a ' - 'descriptor' % event) - return - if not hasattr(path, '__fspath__'): - return - path = _fspath(path) - if isinstance(path, bytes): - try: - path = _fsdecode(path) - except ValueError: - raise _error('%s: undecodable path' % event) from None - absolute = path if path.startswith('/') \ - else _getcwd().rstrip('/') + '/' + path - if opening and need == READ \ - and files.get(_normpath(_abspath(absolute))) == READ: - return - for grant in dirs: - if contained(grant, absolute, need, follow): - return - raise _error('%s: %s is not granted for %s' % (event, path, need)) - - def writes(mode, flags): - """Does this open ask for anything but reading?""" - if isinstance(mode, str) and mode: - return any(c in mode for c in 'wax+') - if isinstance(flags, int): - return bool(flags & _O_WRITES) - return True - - def check_net(kind, event, args): - sock_obj = args[0] if args else None - address = args[1] if len(args) > 1 else None - if not isinstance(address, tuple) or len(address) < 2: - # A Unix socket names a path, but reaching one is talking to - # whatever is behind it, which is not something a directory - # grant says anything about. A descriptor Erlang passed over is - # unaffected: it is connected or listening already. - raise _error( - '%s: a capability set grants no unix-socket or unknown ' - 'address; a descriptor has to come from Erlang' % event) - if net is None: - raise _error('%s: no network was granted' % event) - host, port = address[0], address[1] - try: - addr = _ip_address(host) - except ValueError: - # A rule names addresses, so an unresolved name matches none. - raise _error('%s: %r is not granted' % (event, address)) from None - mapped = getattr(addr, 'ipv4_mapped', None) - if mapped is not None: - addr = mapped - proto = 'udp' if getattr(sock_obj, 'type', None) == _SOCK_DGRAM \ - else 'tcp' - for rule_proto, rule_net, lo, hi in net[kind]: - if rule_proto == proto and lo <= int(port) <= hi \ - and addr in rule_net: - return - raise _error('%s: %r is not granted' % (event, address)) - - def hook(event, args): - if getattr(busy, 'on', False): - return - if event in subprocess_events or event.startswith(exec_prefixes): - raise _error('%s: a capability set grants no subprocess' % event) - if event.startswith(ctypes_prefix): - raise _error( - '%s: a capability set grants no ctypes, which would reach ' - 'past every other rule' % event) - if event in signal_events: - if event == 'os.killpg' or not args or args[0] != _getpid(): - raise _error( - '%s: a capability set grants no signals to other ' - 'processes' % event) - return - if event in resolve_events: - if net is None or not net['resolve']: - raise _error( - '%s: resolution is its own capability and was not ' - 'granted' % event) - return - if event == 'open': - check_path(args[0], WRITE if writes(args[1], args[2]) else READ, - event, opening=True) - elif event in path_events: - need = path_events[event] - follow = event not in name_events - check_path(args[0], need, event, follow) - if event in two_path_events and len(args) > 1: - check_path(args[1], WRITE, event, follow) - elif event in ('socket.connect', 'socket.sendto'): - check_net('connect', event, args) - elif event == 'socket.bind': - check_net('listen', event, args) - - return _Enforcer(hook=hook, walk=walk, contained=contained, - check_path=check_path, writes=writes) - - -def _parse_net(net): - if not net: - return None - out = {'resolve': bool(net.get('resolve')), 'connect': [], 'listen': []} - for kind in ('connect', 'listen'): - for rule in net.get(kind) or (): - lo, hi = rule['ports'] - out[kind].append((rule['proto'], - ipaddress.ip_network(rule['cidr']), - int(lo), int(hi))) - return out - - -def _disarm_unaudited(): - """Take away the calls CPython does not announce. - - `os.mkfifo` and `os.mknod` create something and raise no audit event, so - a hook cannot refuse them. Removing the names is not a boundary either, - but it is the difference between a documented gap and an open one. - """ - import posix - for name in _UNAUDITED_CREATORS: - for module in (os, posix): - if hasattr(module, name): - try: - delattr(module, name) - except (AttributeError, TypeError): - pass - - -def install(caps): - """Install the capability set. Called once, before any user code.""" - global _summary - if _summary is not None: - return [] - st = _State() - problems = [] - st.lexical = os.open not in os.supports_dir_fd - if st.lexical: - problems.append('this platform has no openat: paths are checked ' - 'lexically and a symlink out of a grant is not seen') - - # Everything the interpreter itself reads. Without these nothing - # imports, which is why WASI preopens its sysroot too. - auto = [(p, _READ) for p in [sys.prefix, sys.base_prefix] + list(sys.path) - if p] - named = [(d['path'], d['access']) for d in caps.get('dirs') or ()] - - seen = set() - for path, access in auto + named: - real = os.path.normpath(os.path.abspath(path)) - if (real, access) in seen: - continue - seen.add((real, access)) - try: - st.dirs.append(_Grant(real, access)) - except OSError as exc: - if access != _READ: - problems.append('cannot open granted directory %s: %s' - % (path, exc)) - for dev in _ALWAYS_READ: - st.files[dev] = _READ - st.net = _parse_net(caps.get('net')) - - _disarm_unaudited() - _summary = { - 'dirs': tuple((g.path, g.access) for g in st.dirs), - 'net': None if st.net is None else { - 'connect': tuple(_rule_text(r) for r in st.net['connect']), - 'listen': tuple(_rule_text(r) for r in st.net['listen']), - 'resolve': st.net['resolve'], - }, - 'strict_paths': not st.lexical, - } - sys.addaudithook(_make_enforcer(st).hook) - return problems - - -def installed(): - return _summary is not None - - -def grants(): - """What was granted, for `child_info` and `erlang.caps()`. - - A fresh copy each time: this is something to look at, never something - the hook consults. - """ - if _summary is None: - return None - net = _summary['net'] - return { - 'dirs': [tuple(d) for d in _summary['dirs']], - 'net': None if net is None else { - 'connect': list(net['connect']), - 'listen': list(net['listen']), - 'resolve': net['resolve'], - }, - 'strict_paths': _summary['strict_paths'], - } - - -def _rule_text(rule): - proto, net, lo, hi = rule - return '%s %s %d-%d' % (proto, net, lo, hi) diff --git a/priv/_erlang_impl/_isolated.py b/priv/_erlang_impl/_isolated.py index d6b215f..04f6a97 100644 --- a/priv/_erlang_impl/_isolated.py +++ b/priv/_erlang_impl/_isolated.py @@ -751,12 +751,6 @@ def atom(name): def is_isolated(): return True - def caps(): - """What this child was granted, or None when it holds every - authority the user it runs as holds.""" - from . import _caps - return _caps.grants() - def run(main, *, debug=None): loop = rt.get_loop() if debug is not None: @@ -812,7 +806,7 @@ def __getattr__(name): call=call, async_call=async_call, send=send, whereis=whereis, self=self_, atom=atom, Atom=Atom, Pid=Pid, Ref=Ref, Port=Port, ProcessError=ProcessError, SuspensionRequired=SuspensionRequired, - Function=Function, is_isolated=is_isolated, caps=caps, run=run, + Function=Function, is_isolated=is_isolated, run=run, SharedMemory=SharedMemory, SharedBuffer=SharedBuffer, new_event_loop=new_event_loop, get_event_loop_policy=get_event_loop_policy, install=install, spawn_task=spawn_task, sleep=sleep, log=log, @@ -829,8 +823,7 @@ def __getattr__(name): ByteChannel=_not_supported('erlang.ByteChannel'), __all__=['call', 'async_call', 'send', 'whereis', 'self', 'atom', 'Atom', 'Pid', 'Ref', 'ProcessError', 'SuspensionRequired', - 'run', 'sleep', 'spawn_task', 'server', 'is_isolated', - 'caps'], + 'run', 'sleep', 'spawn_task', 'server', 'is_isolated'], ) mod.__dict__.update(ns) sys.modules['erlang'] = mod diff --git a/priv/py_isolated_child.py b/priv/py_isolated_child.py index 508ccd4..006e074 100644 --- a/priv/py_isolated_child.py +++ b/priv/py_isolated_child.py @@ -38,7 +38,7 @@ def _die(reason): def _parse_args(argv): if len(argv) < 2: _die('usage: py_isolated_child.py SOCKET_PATH [options]') - opts = {'socket': argv[1], 'rlimits': {}, 'cgroup': None, 'caps': None} + opts = {'socket': argv[1], 'rlimits': {}, 'cgroup': None} i = 2 while i < len(argv): flag = argv[i] @@ -48,13 +48,6 @@ def _parse_args(argv): elif flag == '--cgroup': opts['cgroup'] = argv[i + 1] i += 2 - elif flag == '--caps-json': - import json - try: - opts['caps'] = json.loads(argv[i + 1]) - except ValueError as exc: - _die('bad --caps-json: %s' % exc) - i += 2 else: _die('unknown option %s' % flag) return opts @@ -174,15 +167,6 @@ def _connect(path): return sock -def _caps_summary(): - """What was granted, so `py_context:child_info/1` can report it.""" - try: - from _erlang_impl import _caps - return _caps.grants() - except Exception: - return None - - def main(argv): opts = _parse_args(argv) _arm_parent_death() @@ -207,20 +191,10 @@ def main(argv): if _AS_VIA_WATCHDOG and 'as' in opts['rlimits']: _start_memory_watchdog(opts['rlimits']['as'], runtime) - # Last thing before the parent is told this child is ready, so the - # runtime's own imports are not subject to the grants and everything - # that runs afterwards is: the registered imports, the preload, and - # every request. - caps_errors = [] - if opts['caps'] is not None: - from _erlang_impl import _caps - caps_errors = _caps.install(opts['caps']) - - if rlimit_errors or cgroup_error or caps_errors: + if rlimit_errors or cgroup_error: problems = [(Atom('rlimit'), Atom(k), msg) for k, msg in rlimit_errors] if cgroup_error: problems.append((Atom('cgroup'), cgroup_error)) - problems += [(Atom('caps'), msg) for msg in caps_errors] try: runtime.event((Atom('startup_error'), problems)) finally: @@ -231,7 +205,6 @@ def main(argv): Atom('python_version'): '%d.%d.%d' % sys.version_info[:3], Atom('executable'): sys.executable, Atom('platform'): sys.platform, - Atom('caps'): _caps_summary(), } runtime.event((Atom('ready'), info)) diff --git a/priv/tests/test_caps.py b/priv/tests/test_caps.py deleted file mode 100644 index 31bd851..0000000 --- a/priv/tests/test_caps.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Unit tests for the capability resolver and the address matcher. - -These run without a VM, so the containment rules can be read and changed -without a Common Test round trip. `py_isolated_caps_SUITE` covers the same -ground through a real child. - - cd priv && python3 -m unittest tests.test_caps -""" - -import os -import shutil -import sys -import tempfile -import unittest - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from _erlang_impl import _caps # noqa: E402 - - -class PathContainment(unittest.TestCase): - """The tree is the one `wasi_SUITE` uses, so the cases line up.""" - - @classmethod - def setUpClass(cls): - cls.root = tempfile.mkdtemp() - cls.data = os.path.join(cls.root, 'data') - cls.secret = os.path.join(cls.root, 'secret') - os.makedirs(os.path.join(cls.data, 'sub')) - os.makedirs(cls.secret) - _write(os.path.join(cls.data, 'note.txt'), 'inside') - _write(os.path.join(cls.data, 'sub', 'deep.txt'), 'deep') - _write(os.path.join(cls.secret, 'key.txt'), 'secret') - os.symlink(os.path.join(cls.secret, 'key.txt'), - os.path.join(cls.data, 'escape')) - os.symlink(cls.secret, os.path.join(cls.data, 'outdir')) - os.symlink('note.txt', os.path.join(cls.data, 'here')) - os.symlink('loop', os.path.join(cls.data, 'loop')) - cls.grant = _caps._Grant(cls.data, 'write') - # The enforcement lives in the factory's closure, so that is what a - # test drives; there is nothing at module level to call. - state = _caps._State() - state.dirs = [cls.grant] - cls.enf = _caps._make_enforcer(state) - - @classmethod - def tearDownClass(cls): - os.close(cls.grant.fd) - shutil.rmtree(cls.root, ignore_errors=True) - - def reaches(self, rel, follow=True): - """Does `rel` resolve inside the grant?""" - try: - fd, _comp, owned = self.enf.walk(self.grant, rel, follow_last=follow) - except _caps.CapabilityError: - return False - except OSError: - return True # inside the grant, simply not there - if owned: - os.close(fd) - return True - - def test_a_plain_name_resolves(self): - self.assertTrue(self.reaches('note.txt')) - self.assertTrue(self.reaches('./note.txt')) - self.assertTrue(self.reaches('sub/deep.txt')) - - def test_parent_traversal_is_refused(self): - self.assertFalse(self.reaches('../secret/key.txt')) - self.assertFalse(self.reaches('..')) - - def test_partial_traversal_is_refused(self): - self.assertFalse(self.reaches('sub/../../secret/key.txt')) - # And the half of it that is legal really is legal, or the assertion - # above would hold just as well with `..` refused outright. - self.assertTrue(self.reaches('sub/../note.txt')) - - def test_a_symlink_out_is_refused(self): - self.assertFalse(self.reaches('escape')) - - def test_a_symlinked_directory_prefix_is_refused(self): - self.assertFalse(self.reaches('outdir/key.txt')) - - def test_a_symlink_inside_is_followed(self): - self.assertTrue(self.reaches('here')) - - def test_a_cycle_is_refused_rather_than_followed(self): - self.assertFalse(self.reaches('loop')) - - def test_a_name_is_not_followed_when_the_caller_names_it(self): - # Removing a link that leads out of the grant removes something - # inside the grant, so naming it is allowed where following is not. - self.assertFalse(self.reaches('escape', follow=True)) - self.assertTrue(self.reaches('escape', follow=False)) - - def test_a_missing_name_is_not_a_containment_answer(self): - self.assertTrue(self.reaches('missing.txt')) - self.assertFalse(self.reaches('../missing.txt')) - - def test_the_walk_leaks_no_descriptors(self): - before = _lowest_free_fd() - for _ in range(200): - for rel in ('note.txt', 'escape', 'loop', 'sub/../note.txt', - '../secret/key.txt', 'outdir/key.txt'): - self.reaches(rel) - self.assertLessEqual(_lowest_free_fd(), before + 1) - - -class ModuleState(unittest.TestCase): - """The grants must not be reachable through this module. - - An enforcement decision that reads a module attribute is one any Python - in the process can switch off by assigning to it. - """ - - def test_no_lever_is_exported(self): - self.assertFalse(hasattr(_caps, 'allow_path')) - - def test_nothing_on_the_decision_path_lives_at_module_level(self): - # A name the hook resolves when it runs is a name any Python in the - # process can rebind, so none of these may exist here. - for name in ('_walk', '_contained', '_check_path', '_writes', - '_net_allows', '_local', '_state'): - self.assertFalse(hasattr(_caps, name), name) - - def test_the_decision_path_loads_no_module_global(self): - # Not `co_names`, which also lists attribute names: what matters is - # what the code actually loads from the module's namespace, because - # that is what an assignment to this module would change. - import dis - enf = _caps._make_enforcer(_caps._State()) - for part in ('hook', 'walk', 'contained', 'check_path', 'writes'): - code = getattr(enf, part).__code__ - loaded = {i.argval for i in dis.get_instructions(code) - if i.opname == 'LOAD_GLOBAL'} - self.assertEqual(loaded & set(vars(_caps)), set(), part) - - def test_grants_returns_a_copy(self): - # What `grants()` hands back is something to look at, so mutating it - # must not reach anything. - before = _caps.grants() - if before is None: - self.skipTest('no capability set installed in this process') - before['dirs'].append(('/etc', 'write')) - self.assertNotIn(('/etc', 'write'), _caps.grants()['dirs']) - - -class WriteIntent(unittest.TestCase): - """Which opens need a write grant.""" - - def setUp(self): - self.writes = _caps._make_enforcer(_caps._State()).writes - - def test_modes(self): - for mode in ('w', 'a', 'x', 'r+', 'w+b', 'rb+'): - self.assertTrue(self.writes(mode, 0), mode) - for mode in ('r', 'rb', 'rt'): - self.assertFalse(self.writes(mode, 0), mode) - - def test_flags(self): - for flag in (os.O_WRONLY, os.O_RDWR, os.O_CREAT, os.O_TRUNC, - os.O_APPEND, os.O_RDONLY | os.O_CREAT): - self.assertTrue(self.writes(None, flag), flag) - self.assertFalse(self.writes(None, os.O_RDONLY)) - - def test_an_unreadable_intent_is_taken_as_a_write(self): - self.assertTrue(self.writes(None, None)) - - -class AddressMatching(unittest.TestCase): - """The rules erlang_wasm's `wasi_net_SUITE` checks, matched here.""" - - @staticmethod - def grant(connect=(), listen=(), resolve=False): - return _caps._parse_net({'connect': list(connect), - 'listen': list(listen), - 'resolve': resolve}) - - @staticmethod - def rule(cidr, lo, hi, proto='tcp'): - return {'proto': proto, 'cidr': cidr, 'ports': [lo, hi]} - - def allows(self, net, addr, port, kind='connect', dgram=False): - # The matcher lives in the enforcer, so a test builds one over the - # grant it wants rather than poking module state. - state = _caps._State() - state.net = net - enf = _caps._make_enforcer(state) - event = 'socket.bind' if kind == 'listen' else 'socket.connect' - try: - enf.hook(event, (_FakeSocket(dgram), (addr, port))) - return True - except _caps.CapabilityError: - return False - - def test_a_network_and_a_port_range(self): - g = self.grant(connect=[self.rule('10.0.0.0/8', 8000, 8099)]) - self.assertTrue(self.allows(g, '10.1.2.3', 8000)) - self.assertTrue(self.allows(g, '10.255.255.255', 8099)) - self.assertFalse(self.allows(g, '11.0.0.1', 8000)) - self.assertFalse(self.allows(g, '10.1.2.3', 8100)) - self.assertFalse(self.allows(g, '10.1.2.3', 7999)) - - def test_ipv4_mapped_ipv6_is_the_same_address(self): - # A matcher comparing text would let this past a v4 rule. - g = self.grant(connect=[self.rule('127.0.0.0/8', 80, 80)]) - self.assertTrue(self.allows(g, '::ffff:127.0.0.1', 80)) - self.assertFalse(self.allows(g, '::1', 80)) - - def test_udp_and_tcp_are_separate(self): - g = self.grant(connect=[self.rule('127.0.0.1/32', 53, 53, 'udp')]) - self.assertTrue(self.allows(g, '127.0.0.1', 53, dgram=True)) - self.assertFalse(self.allows(g, '127.0.0.1', 53)) - - def test_connect_and_listen_are_separate(self): - g = self.grant(connect=[self.rule('127.0.0.1/32', 80, 80)]) - self.assertTrue(self.allows(g, '127.0.0.1', 80, kind='connect')) - self.assertFalse(self.allows(g, '127.0.0.1', 80, kind='listen')) - - def test_a_wildcard_really_is_a_wildcard(self): - # Nothing is denied implicitly: 0.0.0.0/0 includes the link-local - # and cloud metadata addresses, and this does not second-guess it. - g = self.grant(connect=[self.rule('0.0.0.0/0', 0, 65535)]) - self.assertTrue(self.allows(g, '169.254.169.254', 80)) - - def test_a_name_matches_nothing(self): - # A rule names addresses, so an unresolved name cannot match one. - g = self.grant(connect=[self.rule('0.0.0.0/0', 0, 65535)]) - self.assertFalse(self.allows(g, 'example.com', 80)) - - def test_no_grant_allows_nothing(self): - self.assertFalse(self.allows(None, '127.0.0.1', 80)) - - -class _FakeSocket: - def __init__(self, dgram): - import socket - self.type = socket.SOCK_DGRAM if dgram else socket.SOCK_STREAM - - -def _write(path, text): - with open(path, 'w') as fh: - fh.write(text) - - -def _lowest_free_fd(): - fd = os.dup(0) - os.close(fd) - return fd - - -if __name__ == '__main__': - unittest.main() diff --git a/rebar.config b/rebar.config index 5b7bc6b..ddae020 100644 --- a/rebar.config +++ b/rebar.config @@ -71,7 +71,6 @@ <<"docs/asyncio.md">>, <<"docs/workers.md">>, <<"docs/isolated.md">>, - <<"docs/capabilities.md">>, <<"docs/reactor.md">>, <<"docs/process-bound-envs.md">>, <<"docs/security.md">>, @@ -92,7 +91,6 @@ <<"docs/decisions/0006-shared-memory-over-iommap.md">>, <<"docs/decisions/0007-remove-legacy-execution-paths.md">>, <<"docs/decisions/0008-pipe-io-rules.md">>, - <<"docs/decisions/0009-child-capabilities.md">>, <<"docs/preload.md">>, <<"docs/owngil_internals.md">>, <<"docs/event_loop_architecture.md">> @@ -121,7 +119,6 @@ <<"docs/asyncio.md">>, <<"docs/workers.md">>, <<"docs/isolated.md">>, - <<"docs/capabilities.md">>, <<"docs/reactor.md">>, <<"docs/process-bound-envs.md">>, <<"docs/security.md">>, @@ -148,8 +145,7 @@ <<"docs/decisions/0005-py-isolated-gen-statem.md">>, <<"docs/decisions/0006-shared-memory-over-iommap.md">>, <<"docs/decisions/0007-remove-legacy-execution-paths.md">>, - <<"docs/decisions/0008-pipe-io-rules.md">>, - <<"docs/decisions/0009-child-capabilities.md">> + <<"docs/decisions/0008-pipe-io-rules.md">> ]} ]} ]}. diff --git a/src/erlang_python.app.src b/src/erlang_python.app.src index 57c8879..c478e29 100644 --- a/src/erlang_python.app.src +++ b/src/erlang_python.app.src @@ -1,6 +1,6 @@ {application, erlang_python, [ {description, "Execute Python applications from Erlang using dirty NIFs"}, - {vsn, "5.1.0"}, + {vsn, "5.0.0"}, {registered, []}, {mod, {erlang_python_app, []}}, {applications, [ diff --git a/src/py_caps.erl b/src/py_caps.erl deleted file mode 100644 index 0e8ab2c..0000000 --- a/src/py_caps.erl +++ /dev/null @@ -1,263 +0,0 @@ -%% Copyright 2026 Benoit Chesneau -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% http://www.apache.org/licenses/LICENSE-2.0 -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. - -%%% @doc The `caps' option: what an isolated child may reach. -%%% -%%% A capability grant names directories, environment variables and network -%%% addresses. Anything not named is not reachable. This module reads the -%%% option, refuses what it cannot make sense of, and renders the result as -%%% the JSON the child parses before it runs any user code. -%%% -%%% ``` -%%% #{dirs => [{"/srv/models", read}, {"/var/data/job42", write}], -%%% env => #{<<"MODEL_DIR">> => <<"/srv/models">>}, -%%% net => #{connect => [{tcp, <<"10.0.0.0/8">>, {5432, 5432}}], -%%% listen => [{tcp, <<"127.0.0.1">>, 8080}], -%%% resolve => deny}} -%%% ''' -%%% -%%% A rule is `{Proto, Addr, Port}': `Proto' is `tcp' or `udp', `Addr' is an -%%% address tuple, a binary address or a binary CIDR, and `Port' is an integer, -%%% `{Lo, Hi}' or `any'. Rules name addresses and never host names: a name -%%% would have to be resolved to be checked and resolved again to be used, and -%%% the two answers can differ. Resolution is its own capability and what it -%%% returns carries no authority. -%%% -%%% Checked here rather than in the child, so a typo is a `{bad_caps, _}' -%%% error from `py_context:new/1' rather than a connection refused much later. -%%% A sandbox that silently refuses everything looks exactly like one that -%%% works. -%%% -%%% The rule shape, the IPv4-mapped folding and the masking are taken from -%%% `wasi_net.erl' in erlang_wasm, so a grant means the same thing in both. -%%% -%%% @private -%%% -%%% Shared memory is not granted here and does not work under a capability -%%% set: a region arrives as a path, and granting the directory holding them -%%% would hand over every region this node owns. The way to make it work is -%%% to pass the region's descriptor rather than its name; see -%%% `docs/capabilities.md'. -%%% -%%% Owns: the meaning of the `caps' option and its wire form. -%%% Talks to: `py_context' (validation at `new/1'), `py_isolated' (argv). -%%% Never: enforces anything; the child does that, in -%%% `priv/_erlang_impl/_caps.py'. -%%% @end --module(py_caps). - --export([ - validate/1, - to_json/1 -]). - --export_type([caps/0, access/0]). - --type access() :: read | write. --type rule() :: {tcp | udp, {inet:ip_address(), 0..128}, {0..65535, 0..65535}}. --type net() :: none | #{connect := [rule()], listen := [rule()], - resolve := boolean()}. --type caps() :: #{dirs := [{binary(), access()}], - env := #{binary() => binary()}, - net := net()}. - -%%% ============================================================================ -%%% API -%%% ============================================================================ - -%% @doc Read a `caps' option into the form the child is given. -%% -%% `{error, {bad_caps, Detail}}' names the part that could not be read. --spec validate(term()) -> {ok, caps()} | {error, {bad_caps, term()}}. -validate(Map) when is_map(Map) -> - try - Known = [dirs, env, net], - case maps:keys(maps:without(Known, Map)) of - [] -> ok; - Extra -> throw({unknown_keys, Extra}) - end, - {ok, #{dirs => dirs(maps:get(dirs, Map, [])), - env => env(maps:get(env, Map, #{})), - net => net(maps:get(net, Map, none))}} - catch - throw:Detail -> {error, {bad_caps, Detail}} - end; -validate(Other) -> - {error, {bad_caps, Other}}. - -%% @doc Render a validated grant as the JSON passed to the child in argv. --spec to_json(caps()) -> binary(). -to_json(#{dirs := Dirs, env := Env, net := Net}) -> - iolist_to_binary(json:encode( - #{<<"dirs">> => [#{<<"path">> => P, <<"access">> => atom_to_binary(A)} - || {P, A} <- Dirs], - <<"env">> => Env, - <<"net">> => net_json(Net)})). - -%%% ============================================================================ -%%% Directories and environment -%%% ============================================================================ - -dirs(L) when is_list(L) -> - [dir(D) || D <- L]; -dirs(Other) -> - throw({dirs, Other}). - -%% An absolute path, so that what a grant covers does not depend on the -%% working directory of whoever wrote it. -dir({Path, Access}) when Access =:= read; Access =:= write -> - case to_bin(Path) of - <<"/", _/binary>> = Bin -> {Bin, Access}; - _ -> throw({dir_not_absolute, Path}) - end; -dir(Other) -> - throw({dir, Other}). - -env(Map) when is_map(Map) -> - maps:from_list([{to_bin(K), to_bin(V)} || {K, V} <- maps:to_list(Map)]); -env(Other) -> - throw({env, Other}). - -%%% ============================================================================ -%%% Network grant -%%% -%%% From wasi_net.erl (erlang_wasm), which parses the same rules for a WASM -%%% guest. Kept in step with it deliberately: a grant should mean one thing. -%%% ============================================================================ - -net(none) -> none; -net(undefined) -> none; -net(Map) when is_map(Map) -> - case maps:keys(maps:without([connect, listen, resolve], Map)) of - [] -> ok; - Extra -> throw({net, {unknown_keys, Extra}}) - end, - #{connect => rules(maps:get(connect, Map, [])), - listen => rules(maps:get(listen, Map, [])), - resolve => resolve(maps:get(resolve, Map, deny))}; -net(Other) -> - throw({net, Other}). - -resolve(allow) -> true; -resolve(deny) -> false; -resolve(Other) -> throw({net, {resolve, Other}}). - -rules(L) when is_list(L) -> [rule(R) || R <- L]; -rules(Other) -> throw({net, Other}). - -rule({Proto, Addr, Port}) when Proto =:= tcp; Proto =:= udp -> - {Proto, cidr(Addr), ports(Port)}; -rule(Other) -> - throw({net, {rule, Other}}). - -%% An address with no prefix length is one host: a full-width prefix. -cidr(Bin) when is_binary(Bin) -> - case binary:split(Bin, <<"/">>) of - [Addr] -> host(parse_or_fail(Addr)); - [Addr, Len] -> network(parse_or_fail(Addr), integer_or_fail(Len, Bin), Bin) - end; -cidr(Tuple) when tuple_size(Tuple) =:= 4; tuple_size(Tuple) =:= 8 -> - host(Tuple); -cidr(Other) -> - throw({net, {address, Other}}). - -host(Addr0) -> - Addr = normalise(Addr0), - {Addr, width(Addr)}. - -%% The prefix length is written in the notation the address was written in, so -%% a mapped base has to have its 96 mapping bits taken off with it. Below 96 -%% the prefix spans addresses inside and outside the mapped block at once, -%% which has no IPv4 meaning; refuse rather than guess which half was meant. -network(Addr, Bits, Written) -> - case normalise(Addr) of - A when tuple_size(A) =:= 4, Bits >= 0, Bits =< 32 -> - {mask(A, Bits), Bits}; - A when tuple_size(A) =:= 8, Bits >= 0, Bits =< 128 -> - {mask(A, Bits), Bits}; - V4 when Bits >= 96, Bits =< 128 -> - {mask(V4, Bits - 96), Bits - 96}; - _ -> - throw({net, {address, Written}}) - end. - -ports(any) -> {0, 65535}; -ports(P) when is_integer(P), P >= 0, P =< 65535 -> {P, P}; -ports({Lo, Hi}) when is_integer(Lo), is_integer(Hi), Lo >= 0, Lo =< Hi, - Hi =< 65535 -> {Lo, Hi}; -ports(Other) -> throw({net, {port, Other}}). - -parse_or_fail(Bin) -> - case inet:parse_address(binary_to_list(Bin)) of - {ok, Addr} -> normalise(Addr); - {error, _} -> throw({net, {address, Bin}}) - end. - -integer_or_fail(Bin, Written) -> - try binary_to_integer(Bin) - catch _:_ -> throw({net, {address, Written}}) - end. - -%% Fold an IPv4-mapped IPv6 address onto the IPv4 address it reaches, so -%% `::ffff:127.0.0.1' cannot walk past a `127.0.0.0/8' rule. The deprecated -%% IPv4-compatible block is left alone: `::0.0.0.1' and `::1' are the same -%% address, so folding it would make loopback ambiguous. -normalise({0, 0, 0, 0, 0, 16#ffff, X, Y}) -> - {X bsr 8, X band 16#ff, Y bsr 8, Y band 16#ff}; -normalise(Addr) -> - Addr. - -width(Addr) when tuple_size(Addr) =:= 4 -> 32; -width(Addr) when tuple_size(Addr) =:= 8 -> 128. - -%% Zeroing the host bits, so a rule written `10.1.2.3/8' means the same -%% network as `10.0.0.0/8' rather than never matching anything. -mask(Addr, Bits) -> - W = width(Addr), - from_int(to_int(Addr) band (((1 bsl Bits) - 1) bsl (W - Bits)), W). - -to_int(Addr) -> - Size = part_size(Addr), - lists:foldl(fun(P, Acc) -> (Acc bsl Size) bor P end, 0, tuple_to_list(Addr)). - -from_int(N, 32) -> - <> = <>, - {A, B, C, D}; -from_int(N, 128) -> - <> = <>, - {A, B, C, D, E, F, G, H}. - -part_size(Addr) when tuple_size(Addr) =:= 4 -> 8; -part_size(Addr) when tuple_size(Addr) =:= 8 -> 16. - -%%% ============================================================================ -%%% Wire form -%%% ============================================================================ - -net_json(none) -> - null; -net_json(#{connect := C, listen := L, resolve := R}) -> - #{<<"connect">> => [rule_json(Rule) || Rule <- C], - <<"listen">> => [rule_json(Rule) || Rule <- L], - <<"resolve">> => R}. - -%% The child matches with Python's `ipaddress', so rules cross as the CIDR -%% text that module reads. The address is already masked and folded here, so -%% both sides agree on what a rule covers without parsing it twice. -rule_json({Proto, {Addr, Bits}, {Lo, Hi}}) -> - #{<<"proto">> => atom_to_binary(Proto), - <<"cidr">> => iolist_to_binary([inet:ntoa(Addr), "/", integer_to_list(Bits)]), - <<"ports">> => [Lo, Hi]}. - -to_bin(B) when is_binary(B) -> B; -to_bin(L) when is_list(L) -> list_to_binary(L); -to_bin(A) when is_atom(A) -> atom_to_binary(A, utf8); -to_bin(Other) -> throw({not_a_string, Other}). diff --git a/src/py_context.erl b/src/py_context.erl index 210a74f..757df51 100644 --- a/src/py_context.erl +++ b/src/py_context.erl @@ -188,32 +188,7 @@ stop(Ctx) when is_pid(Ctx) -> new(Opts) when is_map(Opts) -> Mode = maps:get(mode, Opts, worker), Id = erlang:unique_integer([positive]), - case check_caps(Mode, Opts) of - ok -> start_link(Id, Mode, Opts); - {error, _} = Err -> Err - end. - -%% @private A capability grant is only meaningful where the interpreter is a -%% child process. Read it here, in the caller, so a malformed rule is the -%% configuration error it is rather than a connection refused much later. -check_caps(Mode, Opts) -> - case maps:get(caps, Opts, undefined) of - undefined -> - ok; - _ when Mode =/= isolated -> - {error, {caps_requires_isolated, Mode}}; - _ when is_map_key(env, Opts) -> - %% The `env' option adds to the child's environment and a grant - %% says what the whole of it is. Taking both would mean one of - %% them silently losing, so say so instead: `caps.env' is the - %% one that names an environment. - {error, {bad_caps, env_option_conflicts_with_caps_env}}; - Caps -> - case py_caps:validate(Caps) of - {ok, _} -> ok; - {error, _} = Err -> Err - end - end. + start_link(Id, Mode, Opts). %% @doc Alias for stop/1 for API consistency. -spec destroy(context()) -> ok. diff --git a/src/py_isolated.erl b/src/py_isolated.erl index 3a26721..566c4f8 100644 --- a/src/py_isolated.erl +++ b/src/py_isolated.erl @@ -446,38 +446,10 @@ resolve_exe(Exe) -> start_child(#data{opts = Opts} = St) -> case check_platform_opts(Opts) of - ok -> - case caps(Opts) of - {ok, _} -> start_child_1(St); - {error, _} = Err -> Err - end; - {error, _} = Err -> - Err + ok -> start_child_1(St); + {error, _} = Err -> Err end. -%% A grant is read here as well as in py_context:new/1, so a context started -%% by any other route still fails with the configuration error rather than -%% with a child that refuses everything. -caps(Opts) -> - case maps:get(caps, Opts, undefined) of - undefined -> - {ok, none}; - _ when is_map_key(env, Opts) -> - {error, {bad_caps, env_option_conflicts_with_caps_env}}; - Caps -> - case py_caps:validate(Caps) of - {ok, Valid} -> {ok, with_import_paths(Valid, Opts)}; - {error, _} = Err -> Err - end - end. - -%% A directory named in `paths' is one the child was told to import from, so -%% it is granted for reading. Saying it twice would be a trap, and leaving it -%% ungranted turns `paths' into an import error rather than a grant error. -with_import_paths(#{dirs := Dirs} = Caps, Opts) -> - Extra = [{to_bin(P), read} || P <- maps:get(paths, Opts, [])], - Caps#{dirs => Dirs ++ [D || D <- Extra, not lists:member(D, Dirs)]}. - %% cgroups exist only on Linux; rlimits are POSIX and apply everywhere. %% RLIMIT_AS is enforced by the kernel on Linux and FreeBSD; on macOS the %% child enforces `as' with a watchdog thread on its resident set. @@ -511,8 +483,7 @@ spawn_child(Python, Opts) -> ok = socket:bind(L, #{family => local, path => Path}), ok = socket:listen(L), Script = filename:join(priv_dir(), "py_isolated_child.py"), - Args = [Script, Path | rlimit_args(Opts) ++ cgroup_args(Opts) - ++ caps_args(Opts)], + Args = [Script, Path | rlimit_args(Opts) ++ cgroup_args(Opts)], PortOpts = [exit_status, stderr_to_stdout, binary, use_stdio, {args, Args}, {env, env_opt(Opts)}], Port = open_port({spawn_executable, Python}, PortOpts), @@ -1216,35 +1187,8 @@ cgroup_args(Opts) -> Dir -> ["--cgroup", to_list(Dir)] end. -%% The grant travels in argv rather than in the handshake because argv is -%% read in the child's prologue, before the socket exists and before any -%% user code can run. -caps_args(Opts) -> - case caps(Opts) of - {ok, none} -> []; - {ok, Caps} -> ["--caps-json", binary_to_list(py_caps:to_json(Caps))] - end. - -%% Without a grant the child inherits the VM's environment and the `env' -%% option adds to it. With one, it gets what the grant names and nothing -%% else, except the loader variables, which belong to whoever started the -%% node rather than to the workload and without which an interpreter built -%% against a private libpython does not start at all. env_opt(Opts) -> - User = [{to_list(K), to_list(V)} || {K, V} <- maps:to_list(maps:get(env, Opts, #{}))], - case caps(Opts) of - {ok, none} -> - User; - {ok, #{env := Granted}} -> - %% `User' is empty here: a grant and the `env' option together - %% are refused in caps/1, because the port keeps the last of two - %% settings for the same name and the option would win. - Keep = ["LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", - "DYLD_FALLBACK_LIBRARY_PATH"], - Clear = [{Name, false} || {Name, _} <- os:env(), - not lists:member(Name, Keep)], - Clear ++ [{to_list(K), to_list(V)} || {K, V} <- maps:to_list(Granted)] - end. + [{to_list(K), to_list(V)} || {K, V} <- maps:to_list(maps:get(env, Opts, #{}))]. to_bin(A) when is_atom(A) -> atom_to_binary(A, utf8); to_bin(L) when is_list(L) -> unicode:characters_to_binary(L); diff --git a/test/coverage_audit.md b/test/coverage_audit.md index c0a977a..bb8a8ca 100644 --- a/test/coverage_audit.md +++ b/test/coverage_audit.md @@ -34,7 +34,6 @@ suite is visible. `scripts/check_code_map.sh` requires a row per module. | `py_channel` | `py_channel_SUITE`, `py_byte_channel_SUITE` | | `py_byte_channel` | `py_channel_SUITE`, `py_byte_channel_SUITE` | | `py_buffer` | `py_buffer_SUITE`, `py_isolated_buffer_SUITE` | -| `py_caps` | `py_isolated_caps_SUITE` | | `py_shm` | `py_isolated_shm_SUITE` | | `py_import` | `py_import_SUITE` | | `py_preload` | `py_preload_SUITE` | diff --git a/test/py_isolated_caps_SUITE.erl b/test/py_isolated_caps_SUITE.erl deleted file mode 100644 index 6caea88..0000000 --- a/test/py_isolated_caps_SUITE.erl +++ /dev/null @@ -1,628 +0,0 @@ -%% @doc The `caps' option: what an isolated child may reach. -%% -%% The path cases are the ones that matter. A capability set that opens the -%% right files is easy; one that reliably refuses the wrong ones is the whole -%% point. Each escape technique gets its own case, and each is refused as a -%% capability error rather than as a missing file, so the error cannot be used -%% to find out what exists outside a grant. -%% -%% The case names follow `wasi_SUITE' and `wasi_net_SUITE' in erlang_wasm, -%% whose grant model this implements, so the two can be read side by side. --module(py_isolated_caps_SUITE). - --include_lib("common_test/include/ct.hrl"). --include_lib("stdlib/include/assert.hrl"). - --export([all/0, init_per_suite/1, end_per_suite/1, - init_per_testcase/2, end_per_testcase/2]). - --export([ - reads_inside_a_grant/1, - parent_traversal_is_refused/1, - absolute_path_is_refused/1, - partial_traversal_is_refused/1, - symlink_escape_is_refused/1, - symlink_directory_prefix_is_refused/1, - a_symlink_inside_a_grant_is_followed/1, - a_symlink_cycle_is_refused_rather_than_followed/1, - missing_file_inside_a_grant_is_not_a_refusal/1, - a_read_grant_yields_no_write_whatever_the_flags/1, - a_write_grant_allows_create_and_unlink/1, - listing_is_granted_with_the_directory/1, - imports_still_work/1, - a_network_and_a_port_range/1, - an_ungranted_port_is_refused_even_where_something_listens/1, - binding_is_checked_against_listen_not_connect/1, - resolution_is_its_own_capability/1, - resolution_cannot_widen_a_grant/1, - a_wildcard_grant_really_is_a_wildcard/1, - no_net_key_is_no_network/1, - a_passed_fd_still_serves/1, - env_is_what_was_granted_and_nothing_else/1, - subprocess_is_refused/1, - ctypes_is_refused/1, - the_policy_cannot_be_switched_off_from_python/1, - signalling_another_process_is_refused/1, - unaudited_creators_are_taken_away/1, - every_resolver_is_gated_not_only_getaddrinfo/1, - the_env_option_cannot_widen_a_grant/1, - shared_memory_is_refused_under_a_capability_set/1, - a_user_fspath_runs_enforced/1, - a_unix_socket_is_not_a_file/1, - a_read_grant_cannot_become_a_write/1, - caps_survive_a_child_restart/1, - child_info_reports_the_grants/1, - caps_are_rejected_outside_isolated/1, - a_malformed_rule_is_a_configuration_error/1, - no_caps_changes_nothing/1 -]). - --define(TEST_MOD, py_test_caps). - -all() -> - [ - %% filesystem - reads_inside_a_grant, - parent_traversal_is_refused, - absolute_path_is_refused, - partial_traversal_is_refused, - symlink_escape_is_refused, - symlink_directory_prefix_is_refused, - a_symlink_inside_a_grant_is_followed, - a_symlink_cycle_is_refused_rather_than_followed, - missing_file_inside_a_grant_is_not_a_refusal, - a_read_grant_yields_no_write_whatever_the_flags, - a_write_grant_allows_create_and_unlink, - listing_is_granted_with_the_directory, - imports_still_work, - %% network - a_network_and_a_port_range, - an_ungranted_port_is_refused_even_where_something_listens, - binding_is_checked_against_listen_not_connect, - resolution_is_its_own_capability, - resolution_cannot_widen_a_grant, - a_wildcard_grant_really_is_a_wildcard, - no_net_key_is_no_network, - a_passed_fd_still_serves, - %% the rest - env_is_what_was_granted_and_nothing_else, - subprocess_is_refused, - ctypes_is_refused, - the_policy_cannot_be_switched_off_from_python, - signalling_another_process_is_refused, - unaudited_creators_are_taken_away, - every_resolver_is_gated_not_only_getaddrinfo, - the_env_option_cannot_widen_a_grant, - shared_memory_is_refused_under_a_capability_set, - a_user_fspath_runs_enforced, - a_unix_socket_is_not_a_file, - a_read_grant_cannot_become_a_write, - caps_survive_a_child_restart, - child_info_reports_the_grants, - caps_are_rejected_outside_isolated, - a_malformed_rule_is_a_configuration_error, - no_caps_changes_nothing - ]. - -init_per_suite(Config) -> - {ok, _} = application:ensure_all_started(erlang_python), - [{test_dir, filename:join(code:lib_dir(erlang_python), "test")} | Config]. - -end_per_suite(_Config) -> - ok = application:stop(erlang_python), - ok. - -%% A tree with everything the escape cases need, made fresh for each case so -%% one case cannot leave a symlink behind for the next. -%% -%% root/data/note.txt readable -%% root/data/sub/deep.txt readable, one level down -%% root/data/escape -> root/secret/key.txt -%% root/data/outdir -> root/secret -%% root/data/here -> note.txt (stays inside) -%% root/data/loop -> loop -%% root/secret/key.txt never granted -init_per_testcase(TestCase, Config) -> - Root = filename:join(?config(priv_dir, Config), atom_to_list(TestCase)), - Data = filename:join(Root, "data"), - Secret = filename:join(Root, "secret"), - ok = filelib:ensure_path(filename:join(Data, "sub")), - ok = filelib:ensure_path(Secret), - ok = file:write_file(filename:join(Data, "note.txt"), <<"inside">>), - ok = file:write_file(filename:join([Data, "sub", "deep.txt"]), <<"deep">>), - ok = file:write_file(filename:join(Secret, "key.txt"), <<"secret">>), - ok = file:make_symlink(filename:join(Secret, "key.txt"), - filename:join(Data, "escape")), - ok = file:make_symlink(Secret, filename:join(Data, "outdir")), - ok = file:make_symlink("note.txt", filename:join(Data, "here")), - ok = file:make_symlink("loop", filename:join(Data, "loop")), - [{root, Root}, {data, Data}, {secret, Secret} | Config]. - -end_per_testcase(_TestCase, _Config) -> - flush(), - ok. - -%%% ============================================================================ -%%% Filesystem -%%% ============================================================================ - -reads_inside_a_grant(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, <<"inside">>} = read(C, path(Config, "note.txt")), - {ok, <<"deep">>} = read(C, path(Config, "sub/deep.txt")), - {ok, <<"inside">>} = read(C, path(Config, "./note.txt")), - alive(C), - stop(C). - -parent_traversal_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, path(Config, "../secret/key.txt"))), - alive(C), - stop(C). - -absolute_path_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, "/etc/hosts")), - refused(read(C, filename:join(?config(secret, Config), "key.txt"))), - alive(C), - stop(C). - -%% Leaves the grant and comes back. Refused because the path leaves at any -%% point, not merely because of where it ends up; and the half of it that is -%% legal really is legal, or this case would pass just as well with `..' -%% refused outright. -partial_traversal_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, path(Config, "sub/../../secret/key.txt"))), - {ok, <<"inside">>} = read(C, path(Config, "sub/../note.txt")), - alive(C), - stop(C). - -symlink_escape_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, path(Config, "escape"))), - alive(C), - stop(C). - -symlink_directory_prefix_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, path(Config, "outdir/key.txt"))), - alive(C), - stop(C). - -a_symlink_inside_a_grant_is_followed(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, <<"inside">>} = read(C, path(Config, "here")), - alive(C), - stop(C). - -a_symlink_cycle_is_refused_rather_than_followed(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, path(Config, "loop"))), - alive(C), - stop(C). - -%% A file that is not there is not a capability answer. Distinguishing the two -%% is the whole reason refusals are not `FileNotFoundError'. -missing_file_inside_a_grant_is_not_a_refusal(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {error, {'FileNotFoundError', _}} = read(C, path(Config, "missing.txt")), - alive(C), - stop(C). - -a_read_grant_yields_no_write_whatever_the_flags(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(call(C, write_file, [path(Config, "new.txt"), <<"x">>])), - refused(call(C, append_file, [path(Config, "note.txt"), <<"x">>])), - refused(call(C, truncate_file, [path(Config, "note.txt")])), - refused(call(C, remove_file, [path(Config, "note.txt")])), - {ok, <<"inside">>} = file:read_file(path(Config, "note.txt")), - alive(C), - stop(C). - -a_write_grant_allows_create_and_unlink(Config) -> - C = ctx(Config, #{dirs => [{data(Config), write}]}), - {ok, <<"ok">>} = call(C, write_file, [path(Config, "new.txt"), <<"written">>]), - {ok, <<"written">>} = file:read_file(path(Config, "new.txt")), - {ok, <<"ok">>} = call(C, remove_file, [path(Config, "new.txt")]), - false = filelib:is_regular(path(Config, "new.txt")), - %% Still only this grant: the neighbouring directory is untouched. - refused(call(C, write_file, - [filename:join(?config(secret, Config), "x"), <<"x">>])), - alive(C), - stop(C). - -listing_is_granted_with_the_directory(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, Names} = call(C, list_dir, [data(Config)]), - true = lists:member(<<"note.txt">>, Names), - refused(call(C, list_dir, [?config(secret, Config)])), - alive(C), - stop(C). - -%% The interpreter's own path is granted, or nothing would import at all. -imports_still_work(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, <<"[1, 2]">>} = py_context:eval( - C, <<"__import__('json').dumps([1,2])">>), - {ok, 4} = py_context:eval(C, <<"len(__import__('base64').b64encode(b'ab'))">>), - stop(C). - -%%% ============================================================================ -%%% Network -%%% ============================================================================ - -a_network_and_a_port_range(Config) -> - {LSock, Port} = listener(), - C = ctx(Config, #{net => #{connect => [{tcp, <<"127.0.0.0/8">>, - {Port, Port}}]}}), - {ok, <<"connected">>} = call(C, connect, [<<"127.0.0.1">>, Port]), - {ok, _} = gen_tcp:accept(LSock, 2000), - ok = gen_tcp:close(LSock), - alive(C), - stop(C). - -%% Something is accepting on this port, and the answer is the same one a dead -%% port would get: nothing was attempted. -an_ungranted_port_is_refused_even_where_something_listens(Config) -> - {LSock, Port} = listener(), - Granted = free_port(), - C = ctx(Config, #{net => #{connect => [{tcp, <<"127.0.0.1">>, Granted}]}}), - refused(call(C, connect, [<<"127.0.0.1">>, Port])), - {error, timeout} = gen_tcp:accept(LSock, 300), - ok = gen_tcp:close(LSock), - alive(C), - stop(C). - -%% Binding claims a local address, which is what `listen' grants. A connect -%% grant for the same address does not carry it. -binding_is_checked_against_listen_not_connect(Config) -> - Port = free_port(), - C = ctx(Config, #{net => #{connect => [{tcp, <<"127.0.0.1">>, any}], - listen => [{tcp, <<"127.0.0.1">>, Port}]}}), - {ok, <<"bound">>} = call(C, bind, [<<"127.0.0.1">>, Port]), - refused(call(C, bind, [<<"127.0.0.1">>, free_port()])), - alive(C), - stop(C). - -resolution_is_its_own_capability(Config) -> - C = ctx(Config, #{net => #{connect => [{tcp, <<"127.0.0.1">>, any}]}}), - refused(call(C, resolve, [<<"localhost">>])), - stop(C), - C2 = ctx(Config, #{net => #{connect => [{tcp, <<"127.0.0.1">>, any}], - resolve => allow}}), - {ok, _} = call(C2, resolve, [<<"localhost">>]), - alive(C2), - stop(C2). - -%% An address learned by resolving carries no authority from having been -%% resolved: the connect is still checked, and still refused. -resolution_cannot_widen_a_grant(Config) -> - {LSock, Port} = listener(), - C = ctx(Config, #{net => #{connect => [{tcp, <<"10.0.0.0/8">>, any}], - resolve => allow}}), - {ok, Addrs} = call(C, resolve, [<<"localhost">>]), - true = lists:member(<<"127.0.0.1">>, Addrs), - refused(call(C, connect, [<<"127.0.0.1">>, Port])), - {error, timeout} = gen_tcp:accept(LSock, 300), - ok = gen_tcp:close(LSock), - stop(C). - -%% An inverse case: the documented sharp edge is that nothing is denied -%% implicitly, so adding a hidden deny list has to break the build and force -%% the guide to be corrected. -a_wildcard_grant_really_is_a_wildcard(Config) -> - {LSock, Port} = listener(), - C = ctx(Config, #{net => #{connect => [{tcp, <<"0.0.0.0/0">>, any}]}}), - {ok, <<"connected">>} = call(C, connect, [<<"127.0.0.1">>, Port]), - {ok, _} = gen_tcp:accept(LSock, 2000), - ok = gen_tcp:close(LSock), - stop(C). - -no_net_key_is_no_network(Config) -> - {LSock, Port} = listener(), - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(call(C, connect, [<<"127.0.0.1">>, Port])), - C2 = ctx(Config, #{net => #{}}), - refused(call(C2, connect, [<<"127.0.0.1">>, Port])), - {error, timeout} = gen_tcp:accept(LSock, 300), - ok = gen_tcp:close(LSock), - stop(C), - stop(C2). - -%% A socket Erlang opened and handed over needs no grant: the child was given -%% the descriptor, which is the capability. -a_passed_fd_still_serves(Config) -> - {ok, LSock} = gen_tcp:listen(0, [binary, {ip, {127,0,0,1}}, - {active, false}, {backlog, 8}]), - {ok, Port} = inet:port(LSock), - {ok, Fd} = inet:getfd(LSock), - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, ChildFd} = py_context:pass_fd(C, Fd), - ok = py_context:start_loop(C), - {ok, _} = py_context:submit_await(C, ?TEST_MOD, serve, [ChildFd], #{}, 10000), - {ok, Sock} = gen_tcp:connect({127,0,0,1}, Port, [binary, {active, false}], 2000), - ok = gen_tcp:send(Sock, <<"ping">>), - {ok, <<"pong">>} = gen_tcp:recv(Sock, 4, 5000), - ok = gen_tcp:close(Sock), - ok = gen_tcp:close(LSock), - ok = py_context:stop_loop(C), - stop(C). - -%%% ============================================================================ -%%% Environment, processes, shared memory, lifecycle -%%% ============================================================================ - -env_is_what_was_granted_and_nothing_else(Config) -> - true = os:putenv("EP_CAPS_SECRET", "leaked"), - C = ctx(Config, #{env => #{<<"EP_CAPS_GRANTED">> => <<"yes">>}}), - {ok, <<"yes">>} = call(C, getenv, [<<"EP_CAPS_GRANTED">>]), - {ok, none} = call(C, getenv, [<<"EP_CAPS_SECRET">>]), - {ok, none} = call(C, getenv, [<<"HOME">>]), - stop(C), - %% Without a capability set the child inherits as it always did. - C2 = ctx(Config, no_caps), - {ok, <<"leaked">>} = call(C2, getenv, [<<"EP_CAPS_SECRET">>]), - stop(C2), - true = os:unsetenv("EP_CAPS_SECRET"). - -subprocess_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(call(C, run_subprocess, [])), - refused(py_context:eval(C, <<"__import__('os').fork()">>)), - alive(C), - stop(C). - -ctypes_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(py_context:eval(C, <<"__import__('ctypes').CDLL(None)">>)), - alive(C), - stop(C). - -%% The hook must not read anything the workload can assign to, or the policy -%% is off the moment code says so. -the_policy_cannot_be_switched_off_from_python(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, "/etc/hosts")), - %% Every name the hook used to resolve when it ran, assigned at once. - ok = py_context:exec(C, <<"import _erlang_impl._caps as c\n" - "c._summary = None\n" - "c._local = type('x', (), {'busy': True})()\n" - "c._writes = lambda *a: False\n" - "c._PATH_EVENTS = {}\n" - "c._RESOLVE_EVENTS = frozenset()\n" - "c._SUBPROCESS_EVENTS = frozenset()\n" - "c._make_enforcer = None\n" - "c.os = None\n">>), - refused(read(C, "/etc/hosts")), - refused(call(C, run_subprocess, [])), - %% And the levers that used to let Python widen a grant are gone. - {ok, false} = py_context:eval( - C, <<"hasattr(__import__('_erlang_impl._caps', fromlist=['x'])," - " 'allow_path')">>), - {ok, false} = py_context:eval( - C, <<"hasattr(__import__('_erlang_impl._caps', fromlist=['x'])," - " '_walk')">>), - alive(C), - stop(C). - -%% The child shares the node's user and its parent is the BEAM, so an -%% unchecked signal is a way to take the node down. -signalling_another_process_is_refused(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(py_context:eval(C, <<"__import__('os').kill(__import__('os')" - ".getppid(), 0)">>)), - refused(py_context:eval(C, <<"__import__('os').killpg(__import__('os')" - ".getpgrp(), 0)">>)), - refused(py_context:eval(C, <<"__import__('os').kill(1, 0)">>)), - %% Signalling itself is its own business. - {ok, none} = py_context:eval(C, <<"__import__('os').kill(__import__('os')" - ".getpid(), 0)">>), - alive(C), - stop(C). - -%% CPython raises no audit event for these, so they cannot be refused and -%% are taken away instead. Their absence is the assertion. -unaudited_creators_are_taken_away(Config) -> - C = ctx(Config, #{dirs => [{data(Config), write}]}), - {ok, false} = py_context:eval(C, <<"hasattr(__import__('os'), 'mkfifo')">>), - {ok, false} = py_context:eval(C, <<"hasattr(__import__('os'), 'mknod')">>), - {ok, false} = py_context:eval(C, <<"hasattr(__import__('posix'), 'mkfifo')">>), - {ok, false} = py_context:eval(C, <<"hasattr(__import__('posix'), 'mknod')">>), - alive(C), - stop(C). - -%% Gating only getaddrinfo would leave every other resolver as a way out, -%% and a name lookup is a message to whoever answers it. -every_resolver_is_gated_not_only_getaddrinfo(Config) -> - C = ctx(Config, #{net => #{connect => [{tcp, <<"127.0.0.1">>, any}]}}), - refused(call(C, resolve, [<<"localhost">>])), - refused(py_context:eval(C, <<"__import__('socket').gethostbyname('localhost')">>)), - refused(py_context:eval(C, <<"__import__('socket').gethostbyname_ex('localhost')">>)), - refused(py_context:eval(C, <<"__import__('socket').gethostbyaddr('127.0.0.1')">>)), - refused(py_context:eval(C, <<"__import__('socket').getnameinfo(('127.0.0.1',80),0)">>)), - refused(py_context:eval(C, <<"__import__('socket').gethostname()">>)), - alive(C), - stop(C). - -%% The `env' option adds to the environment and a grant says what the whole -%% of it is; the port keeps the last setting for a name, so taking both -%% would let the option quietly win. -the_env_option_cannot_widen_a_grant(_Config) -> - {error, {bad_caps, env_option_conflicts_with_caps_env}} = - py_context:new(#{mode => isolated, - caps => #{env => #{<<"A">> => <<"1">>}}, - env => #{<<"SECRET">> => <<"leaked">>}}), - {error, {bad_caps, env_option_conflicts_with_caps_env}} = - py_context:new(#{mode => isolated, caps => #{}, - env => #{<<"SECRET">> => <<"leaked">>}}), - ok. - -%% Shared memory does not combine with a capability set yet: a region -%% arrives as a path, and the only way to grant it would be to hand over the -%% directory holding every region this node owns. Passing the descriptor is -%% the fix, and it is not here yet, so the refusal has to be legible. -shared_memory_is_refused_under_a_capability_set(Config) -> - case py_shm:available() of - false -> - {skip, "iommap not available"}; - true -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, Shm} = py_shm:new(4096), - refused(py_context:call(C, ?TEST_MOD, shm_write, - [Shm, <<"payload">>], #{}, 10000)), - %% Erlang still has it, unharmed. - ok = py_shm:write(Shm, 0, <<"payload">>), - {ok, <<"payload">>} = py_shm:read(Shm, 0, 7), - alive(C), - ok = py_shm:close(Shm), - stop(C) - end. - -%% Path conversion happens before the re-entrancy guard, so a `__fspath__' -%% method is user code that runs with the hook live rather than a window in -%% which everything is allowed. -a_user_fspath_runs_enforced(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - {ok, false} = py_context:call(C, ?TEST_MOD, read_through_fspath, - [path(Config, "note.txt")], #{}, 10000), - alive(C), - stop(C). - -%% Reaching a Unix socket is talking to whatever is behind it, which a -%% directory grant says nothing about. -a_unix_socket_is_not_a_file(Config) -> - %% Its own short directory: an AF_UNIX path is capped near 104 bytes and - %% `connect' rejects a longer one before the hook ever sees it, which - %% would make this case pass for the wrong reason. - Dir = "/tmp/ep_caps_u" ++ integer_to_list(erlang:unique_integer([positive])), - ok = filelib:ensure_path(Dir), - C = ctx(Config, #{dirs => [{Dir, write}]}), - try - %% The directory is granted for writing, so the path is reachable as - %% a file; talking through it is not what that grant said. - {ok, <<"ok">>} = call(C, write_file, [Dir ++ "/plain", <<"x">>]), - refused(call(C, unix_connect, [Dir ++ "/sock"])), - refused(call(C, unix_bind, [Dir ++ "/mine.sock"])), - alive(C) - after - stop(C), - _ = file:del_dir_r(Dir) - end. - -%% Every route from a read grant to a write, including the ones CPython -%% does not announce by path. -a_read_grant_cannot_become_a_write(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(call(C, write_file, [path(Config, "note.txt"), <<"x">>])), - refused(call(C, truncate_by_descriptor, [path(Config, "note.txt")])), - {ok, <<"inside">>} = file:read_file(path(Config, "note.txt")), - alive(C), - stop(C). - -caps_survive_a_child_restart(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}]}), - refused(read(C, "/etc/hosts")), - ok = py_context:kill(C), - {ok, <<"inside">>} = read(C, path(Config, "note.txt")), - refused(read(C, "/etc/hosts")), - refused(call(C, run_subprocess, [])), - stop(C). - -child_info_reports_the_grants(Config) -> - C = ctx(Config, #{dirs => [{data(Config), read}], - net => #{connect => [{tcp, <<"10.0.0.0/8">>, 443}]}}), - {ok, Info} = py_context:child_info(C), - Caps = maps:get(caps, Info), - Dirs = maps:get(<<"dirs">>, Caps), - Bin = list_to_binary(data(Config)), - true = lists:keymember(Bin, 1, Dirs), - #{<<"connect">> := [<<"tcp 10.0.0.0/8 443-443">>]} = maps:get(<<"net">>, Caps), - stop(C). - -caps_are_rejected_outside_isolated(_Config) -> - {error, {caps_requires_isolated, worker}} = - py_context:new(#{mode => worker, caps => #{}}), - {error, {caps_requires_isolated, owngil}} = - py_context:new(#{mode => owngil, caps => #{}}), - ok. - -%% A malformed rule is a configuration error and is reported as one here, -%% rather than met later as a refused connection: a capability set that -%% silently refuses everything looks exactly like one that works. -a_malformed_rule_is_a_configuration_error(_Config) -> - {error, {bad_caps, {net, {address, <<"nope">>}}}} = - py_context:new(#{mode => isolated, - caps => #{net => #{connect => [{tcp, <<"nope">>, 80}]}}}), - {error, {bad_caps, {dir_not_absolute, "rel"}}} = - py_context:new(#{mode => isolated, caps => #{dirs => [{"rel", read}]}}), - {error, {bad_caps, {net, {port, -1}}}} = - py_context:new(#{mode => isolated, - caps => #{net => #{listen => [{tcp, <<"127.0.0.1">>, -1}]}}}), - {error, {bad_caps, {unknown_keys, [bogus]}}} = - py_context:new(#{mode => isolated, caps => #{bogus => 1}}), - ok. - -no_caps_changes_nothing(Config) -> - C = ctx(Config, no_caps), - {ok, _} = read(C, "/etc/hosts"), - {ok, 4} = py_context:eval(C, <<"2+2">>), - stop(C). - -%%% ============================================================================ -%%% Helpers -%%% ============================================================================ - -ctx(Config, no_caps) -> - new(Config, #{}); -ctx(Config, Caps) -> - new(Config, #{caps => Caps}). - -new(Config, Extra) -> - TestDir = ?config(test_dir, Config), - Opts = maps:merge(#{mode => isolated, paths => [TestDir]}, Extra), - {ok, C} = py_context:new(Opts), - C. - -stop(C) -> - ok = py_context:stop(C). - -%% Every case ends with the context still working: a refusal must not have -%% left the child broken. -alive(C) -> - {ok, 4} = py_context:eval(C, <<"2+2">>). - -data(Config) -> ?config(data, Config). - -path(Config, Rel) -> filename:join(data(Config), Rel). - -read(C, Path) -> - call(C, read_file, [to_bin(Path)]). - -call(C, Fun, Args) -> - py_context:call(C, ?TEST_MOD, Fun, [to_bin(A) || A <- Args], #{}, 10000). - -%% A capability error, and never a missing-file error: the refusal says -%% nothing about whether the path exists. -refused({error, {'CapabilityError', _}}) -> ok; -refused(Other) -> ct:fail({expected_refusal, Other}). - -listener() -> - {ok, LSock} = gen_tcp:listen(0, [binary, {ip, {127,0,0,1}}, - {active, false}, {backlog, 8}]), - {ok, Port} = inet:port(LSock), - {LSock, Port}. - -free_port() -> - {ok, S} = gen_tcp:listen(0, [{ip, {127,0,0,1}}]), - {ok, P} = inet:port(S), - ok = gen_tcp:close(S), - P. - -to_bin(B) when is_binary(B) -> B; -to_bin(L) when is_list(L) -> list_to_binary(L); -to_bin(I) when is_integer(I) -> I; -to_bin(T) when is_tuple(T) -> T. - -flush() -> - receive _ -> flush() after 0 -> ok end. diff --git a/test/py_test_caps.py b/test/py_test_caps.py deleted file mode 100644 index 1d32e93..0000000 --- a/test/py_test_caps.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Helpers for py_isolated_caps_SUITE. - -Each one is the smallest Python that performs one operation a capability set -either grants or refuses. They return a plain term on success and let the -exception through on refusal, so the suite sees `{error, {'CapabilityError', -Msg}}` and can tell it apart from a missing file. -""" - -import asyncio -import os -import socket -import subprocess - -_servers = {} - - -def _bytes(value): - """Erlang binaries arrive as `str`; `{bytes, B}` is what arrives as bytes.""" - return value.encode() if isinstance(value, str) else value - - -def _text(value): - return value.decode() if isinstance(value, bytes) else value - - -# --- filesystem ------------------------------------------------------------- - -def read_file(path): - with open(path, 'rb') as fh: - return fh.read() - - -def write_file(path, data): - with open(path, 'wb') as fh: - fh.write(_bytes(data)) - return 'ok' - - -def append_file(path, data): - with open(path, 'ab') as fh: - fh.write(_bytes(data)) - return 'ok' - - -def truncate_file(path): - os.truncate(path, 0) - return 'ok' - - -def remove_file(path): - os.remove(path) - return 'ok' - - -def list_dir(path): - return sorted(os.listdir(path)) - - -# --- network ---------------------------------------------------------------- - -def connect(host, port): - sock = socket.socket() - try: - sock.settimeout(5) - sock.connect((_text(host), port)) - return 'connected' - finally: - sock.close() - - -def bind(host, port): - sock = socket.socket() - try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((_text(host), port)) - return 'bound' - finally: - sock.close() - - -def resolve(name): - name = _text(name) - return sorted({info[4][0] for info in socket.getaddrinfo(name, 80)}) - - -class _Echo(asyncio.Protocol): - def connection_made(self, transport): - self.transport = transport - - def data_received(self, data): - self.transport.write(b'pong') - - -async def serve(fd): - """Accept on a descriptor Erlang passed over and answer one request.""" - import erlang - _servers[fd] = await erlang.server.serve(fd, _Echo) - return 'serving' - - -# --- the rest --------------------------------------------------------------- - -def getenv(name): - name = _text(name) - value = os.environ.get(name) - return None if value is None else value - - -def run_subprocess(): - subprocess.run(['true'], check=False) - return 'ran' - - -def shm_write(region, data): - data = _bytes(data) - region[0:len(data)] = data - return 'ok' - - -class _Fspath: - """A path object whose conversion tries to read outside every grant.""" - - def __init__(self, path): - self.path = path - self.leaked = None - - def __fspath__(self): - try: - with open('/etc/hosts'): - self.leaked = True - except Exception: - self.leaked = False - return self.path - - -def read_through_fspath(path): - """Did the conversion get an unchecked read? It must not.""" - obj = _Fspath(_text(path)) - try: - with open(obj): - pass - except Exception: - pass - return obj.leaked - - -def truncate_by_descriptor(path): - fd = os.open(_text(path), os.O_RDONLY) - try: - os.truncate(fd, 0) - return 'truncated' - finally: - os.close(fd) - - -def unix_connect(path): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.connect(_text(path)) - return 'connected' - finally: - sock.close() - - -def unix_bind(path): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.bind(_text(path)) - return 'bound' - finally: - sock.close()