diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 56a86e7..8ed55d2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ 1. Finish the authentication slice. Done: Keycloak login, `member`/`lead`/`admin` realm roles, subject-based project ownership, role and private-project authorization tests, and configurable branding, and a Flyway baseline migration with Hibernate schema validation. Remaining: integration tests against a real Keycloak and server configuration. 2. Repository access. Done: JGit fetch of public HTTPS repositories on `github.com` and `gitlab.com`, private access through environment-variable credential references (admins only), and the commit SHA recorded per build, including after a force push. Remaining: private credentials stored using a secrets provider, and project membership and repository permissions. -3. Manual builds. Done: a single in-process runner using Docker or Podman with no network, CPU/memory/PID limits, a timeout and stored logs; builds left running by a restart are marked failed. Persistent FIFO queue with stored request details, atomic claims, configurable concurrency and queue limits, and restart recovery. Remaining: cancellation, container cleanup after a crash, and a separately isolated Linux worker using disposable rootless containers with a restricted network and no container-engine socket or host credentials exposed. Define these before enabling untrusted builds. +3. Manual builds. Done: a single in-process runner using Docker or Podman with no network for steps by default and, with Podman, HTTPS access to admin-allowed hosts through a per-build egress proxy that always denies internal and link-local addresses, CPU/memory/PID limits, a timeout and stored logs; builds left running by a restart are marked failed. Persistent FIFO queue with stored request details, atomic claims, configurable concurrency and queue limits, and restart recovery. Remaining: cancellation, container cleanup after a crash, and a separately isolated Linux worker using disposable rootless containers with a restricted network and no container-engine socket or host credentials exposed. Define these before enabling untrusted builds. 4. Multi-user deployment: TLS, secure Keycloak settings and secrets, database backups, audit records, access control, rate limits, concurrency controls and tested upgrade/migration procedures. 5. CI configuration, webhooks, artifacts and additional worker platforms. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index bb436ec..34a0fae 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -70,11 +70,34 @@ Controls that are in place * Only HTTPS repositories on an allowed host are accepted for builds and branch discovery; redirects, embedded credentials, query strings, fragments and non-default ports are rejected. See [Repository addresses](#repository-addresses). * Branch discovery for private projects is not attempted because credentials are not stored on the project; the endpoint returns an empty list with reason `private_repository`. * Branch names are validated as Git references. Builds may only use images an admin has allowed; image names are validated against a restrictive pattern. -* The branch is fetched afresh with JGit at depth 1 to read its workflow file and commit ID. The build then builds an image from the workflow: it installs `git`, `make`, `podman` and `podman-compose` and the workflow's `dependencies` as root, with network access, clones the branch at that commit as `65534:65534`, and runs the steps as `65534:65534` without network. The installs and the clone have network access. +* The branch is fetched afresh with JGit at depth 1 to read its workflow file and commit ID. The build then builds an image from the workflow: it installs `git`, `make`, `podman` and `podman-compose` and the workflow's `dependencies` as root, with network access, clones the branch at that commit as `65534:65534`, and runs the steps as `65534:65534` without network, except through the egress proxy (see [Network for build steps](#network-for-build-steps)). The installs and the clone have network access. * The image build runs with `no-new-privileges`, 1 GiB of memory and one CPU under Podman; Docker applies neither limit. The image is removed when the build ends. * Builds run up to the concurrency limit (default 1, at most 16 when set by an admin); further requests wait in a bounded queue. Each build times out after ten minutes. Output is stored in full, up to the `BUILD_LOG_MAX_BYTES` safety limit (default 1 GiB); output beyond it is discarded and the build is marked truncated. * Builds left in the `RUNNING` state by a server restart are marked `FAILED` at startup. Startup also removes leftover `cisys-build-*` containers and images and `cisys-*` checkout directories from an unclean stop; a cleanup failure is logged and does not stop startup. +### Network for build steps + +Steps run with `--network=none`. An admin can allow destinations under **Settings → Network**; an empty list (the default) means steps have no network at all. + +How it is enforced + +* With a non-empty list, the backend starts one `EgressProxy` per build on a Unix socket in the build's private temporary directory (`0700`). The socket's own directory is `0711`, so the step user can reach the socket but not list it. Podman mounts that directory read-only into every `RUN` (`--volume …:/run/cisys:ro,Z`). The step keeps `--network=none`; `socat` forwards `127.0.0.1:3128` in the step to the socket. Bypassing the proxy variables therefore gains nothing: there is no other route out. +* The proxy accepts HTTP `CONNECT host:port` only. The port must be in `cisys.build.network.ports` (`BUILD_NETWORK_PORTS`, default `443`). The host must match an entry: an exact name, `*.suffix` for subdomains, or a public IP or CIDR range. +* Protected destinations are always denied, whatever the list says: loopback, private, CGNAT, link-local (including `169.254.169.254`), multicast, documentation and reserved ranges, IPv4-mapped, IPv4-compatible, NAT64, 6to4 and Teredo forms, every address of the backend host's interfaces, and the hosts and addresses of the datasource, Keycloak and `quarkus.http.host`. Entries that are or overlap protected ranges are rejected when saved. +* The proxy resolves the host once, checks every address it resolves to, and connects to the checked address, so a DNS change cannot redirect an approved tunnel. +* Fail closed: an unresolvable host, a protected address, a refused or timed-out connection, or any other request is denied and logged in the build log as `==> Network denied: host:port (reason)`. A stored list that no longer validates, a proxy that cannot start, or internal service hosts that cannot be resolved fail the build. A build never falls back to ordinary networking. +* The proxy limits concurrent connections (64), the time to send the request (10 s), the connect time (10 s) and idle tunnels (5 min). It is closed, and its socket and directory removed, on every exit path of the build. + +Docker and Podman + +* Tested on Podman 4.9 and Docker 29.9 with BuildKit (buildx 0.38), on 2026-11-14. With rootless Podman, the mounted socket worked from `RUN --network=none` as `65534:65534`, and so did several concurrent connections, while direct connections failed with *Network is unreachable*. +* Docker's builder can forward a socket only through `--ssh`, which accepts the connection but passes no data unless it speaks the SSH agent protocol. Docker builds cannot mount host directories. With Docker, a build with a non-empty list therefore fails with `Network access is not supported with docker`. + +Residual risk + +* The proxy sees only the host and port, not the paths or the content inside TLS. Any allowed host can receive whatever a step sends it, so an allowed host is a possible exfiltration channel; a wildcard such as `*.example.com` widens that to every subdomain. Allow only registries you trust, and prefer exact names. +* Installing the tools and dependencies and cloning the branch still have ordinary network access, as before. + ### Repository addresses * Only HTTPS repositories on `cisys.git.allowed-hosts` are accepted; the default is `github.com` and `gitlab.com`. diff --git a/docs/manual/en/03-roles.md b/docs/manual/en/03-roles.md index e88fb64..926c198 100644 --- a/docs/manual/en/03-roles.md +++ b/docs/manual/en/03-roles.md @@ -14,7 +14,7 @@ The **Lead** role is for people who run projects. Leads can do everything a memb ## Admin -The **Admin** role manages the installation. Admins can see every project including private projects of other users, start and pin builds, use credential references for private Git repositories, manage the build images, change the branding, and change the settings such as build retention and the build queue. +The **Admin** role manages the installation. Admins can see every project including private projects of other users, start and pin builds, use credential references for private Git repositories, manage the build images, change the branding, and change the settings such as build retention, the build queue and the hosts build steps may reach. ## Access summary @@ -30,7 +30,7 @@ The **Admin** role manages the installation. Admins can see every project includ | Use a Git credential reference | no | no | yes | | Manage build images | no | no | yes | | Change branding | no | no | yes | -| Change settings (retention, queue) | no | no | yes | +| Change settings (retention, queue, network) | no | no | yes | "View" always means projects the user may access. The address of a project or build that does not exist or is not visible to the user opens the front page instead, so a private project is not revealed; the same applies to any unknown address. diff --git a/docs/manual/en/05-builds.md b/docs/manual/en/05-builds.md index 78daaca..b837b51 100644 --- a/docs/manual/en/05-builds.md +++ b/docs/manual/en/05-builds.md @@ -19,7 +19,7 @@ Members can view build history and logs for the projects they can access. Privat 1. The branch is fetched afresh with JGit at depth 1, so a force push is reflected in the next build, and its commit ID is recorded. 2. The branch's `.cisys/workflows/ci.yml` is read and stored with the build, then validated. A branch without the file, an invalid file, or a `container` image that is not on the build host or that an admin has not allowed on the **Images** page fails the build with a message in the log. -3. The workflow becomes a Containerfile. Podman builds it with `no-new-privileges`, 1 GiB of memory, one CPU and no layer cache. The image installs `git`, `make`, `podman` and `podman-compose` if they are missing and the workflow's `dependencies`, clones the branch at the recorded commit, and runs the steps as `65534:65534` without network. See **Workflows**. +3. The workflow becomes a Containerfile. Podman builds it with `no-new-privileges`, 1 GiB of memory, one CPU and no layer cache. The image installs `git`, `make`, `podman` and `podman-compose` if they are missing and the workflow's `dependencies`, clones the branch at the recorded commit, and runs the steps as `65534:65534` without network. When admins allow hosts under **Settings** → **Network**, steps reach those hosts over HTTPS through the egress proxy and nothing else (Podman only). See **Workflows**. 4. The status is `PASSED` when the image build succeeds, otherwise `FAILED` with the exit code of the failing step. A build that exceeds ten minutes is stopped and reported as timed out. The image is removed afterwards. Build requests are stored as `QUEUED` and claimed in request order. By default one build runs at a time. Set `BUILD_CONCURRENCY` (`cisys.build.concurrency`) to change the number of concurrent builds and `BUILD_QUEUE_LIMIT` (`cisys.build.queue-limit`, default 100) to limit waiting requests. Both limits must be positive. Admins can change both under **Settings** → **Queue** (1–16 concurrent builds, 1–1000 queued builds); saved values replace the configured ones. A full queue returns HTTP 429 with a clear error. Requests keep their repository URL, branch and credential reference; credentials themselves are read from the environment when execution begins. @@ -85,7 +85,7 @@ An image that is allowed but no longer present locally is listed separately, whe The runner is always on. It uses Podman when it is installed and Docker otherwise. -**Trust boundary:** the runner allows trusted leads and admins to execute code through the local container daemon. Docker socket access grants host-level control to the backend. These limits are not a hostile-code sandbox. Use a dedicated machine, disk quotas and trusted repositories, and do not expose this setup to the Internet. Repository checkout sizes are not bounded. Installing the build tools and cloning the branch happen with network access, as root and as `65534:65534` respectively; the workflow's steps run without network. +**Trust boundary:** the runner allows trusted leads and admins to execute code through the local container daemon. Docker socket access grants host-level control to the backend. These limits are not a hostile-code sandbox. Use a dedicated machine, disk quotas and trusted repositories, and do not expose this setup to the Internet. Repository checkout sizes are not bounded. Installing the build tools and cloning the branch happen with network access, as root and as `65534:65534` respectively; the workflow's steps run without network, or reach the allowed hosts through the egress proxy only. An allowed host can receive anything a step sends it; allow only registries you trust. ## Verification status diff --git a/docs/manual/en/08-workflows.md b/docs/manual/en/08-workflows.md index ae1b0d2..b8b9511 100644 --- a/docs/manual/en/08-workflows.md +++ b/docs/manual/en/08-workflows.md @@ -130,7 +130,7 @@ The build finds out what the container is and installs with its package manager: * `value` is the package name in the container's distribution, so it depends on `container`: Java is `openjdk25` on Alpine, `openjdk-25-jdk-headless` on Debian and `java-25-openjdk-devel` on Fedora. A version can be given as the package manager writes it, for example `git=1:2.47.3-0+deb13u1` for `apt-get`. * `value` is one package: letters, digits and `+ . _ : = ~ @ -`, starting with a letter or digit, at most 200 characters. Options such as `-y`, spaces and shell characters are rejected. * `name` is required, at most 200 characters, and appears once in the list. -* Packages are installed as root, with network access. The steps themselves still run as `65534:65534` without network. +* Packages are installed as root, with network access. The steps themselves still run as `65534:65534` without network, except through the proxy described under **Network for steps**. * All dependencies are installed together, in one step, before the branch is cloned, so every job and step can use them. `dependencies` on a job or a step is rejected. * A package that does not exist, or a container without a supported package manager, fails the build with `==> Could not install …` or `==> Cannot install dependencies …`. * Dependencies are always up to date. The package manager reads fresh package metadata on every build, never what the image cached, and installs the newest version. A package the image already has is upgraded. The log shows the version each one ended up at, for example `==> Installed: jq 1.7.1-6+deb13u4`. To stay on one version, give it in `value`. @@ -164,7 +164,7 @@ Every build image contains `git`, `make`, `podman` and `podman-compose`. The bui The base image also needs `sh`. Installing the tools downloads packages on every build. A base image that already contains them starts faster. -`podman` and `podman-compose` are installed, but containers cannot yet be started from a step: steps run as the unprivileged user `65534:65534`, with no subordinate UID range, no network, and inside the build's own container storage. +`podman` and `podman-compose` are installed, but containers cannot yet be started from a step: steps run as the unprivileged user `65534:65534`, with no subordinate UID range, no direct network, and inside the build's own container storage. ## How a workflow is built @@ -174,7 +174,7 @@ A build turns the workflow into a Containerfile and builds an image from it with 2. **Base.** `container` is looked up among the local images, and the image starts `FROM` its ID. Nothing is pulled. 3. **Tools and dependencies.** `git`, `make`, `podman` and `podman-compose` are installed as root if the image lacks them, then the workflow's `dependencies`. This step has network access. 4. **Checkout.** As the unprivileged user `65534:65534`, the branch is cloned into `/workspace` at depth 1. The build then checks that the clone is at the recorded commit; if the branch moved in the meantime, the build fails. The working tree is **on the branch being tested**, with its `.git` directory, so `git status`, `git log -1` and `git rev-parse HEAD` work. `HOME` is `/tmp`. -5. **Steps.** The jobs and steps run in order as `65534:65534`, in `/workspace`, **without network**, with the workflow's, the job's and the step's `environment` variables set. The log marks every job and step with `==> Job:` and `==> Step:` lines. +5. **Steps.** The jobs and steps run in order as `65534:65534`, in `/workspace`, **without network** (except through the egress proxy, see **Network for steps**), with the workflow's, the job's and the step's `environment` variables set. The log marks every job and step with `==> Job:` and `==> Step:` lines. 6. **Result.** The first failing step stops the build. The log shows `==> Step failed:` with the step name and exit code, and the build records that exit code and the status `FAILED`. When every step succeeds, the build is `PASSED`. 7. **Cleanup.** The image is removed when the build ends. The log, the workflow text, the commit ID and the base image digest are kept. @@ -187,10 +187,25 @@ The whole image build has ten minutes. After that it is stopped and reported as * A job **with** a `wait` step runs as one `RUN`. A service started in the background by one step, such as `make platform-start`, therefore keeps running for the later steps of that job and stops when the job ends. * A `wait` step checks the kernel's listening sockets (`/proc/net/tcp` and `/proc/net/tcp6`) once a second, for up to five minutes, so it needs only `sh` and `grep`. If the port is not listening by then, the step fails. -### No network for steps +### Network for steps -Steps cannot reach the network. A step that downloads dependencies, such as a `mvn` or `npm install` without a local cache, fails. Use a base image that already contains the tools and dependencies the build needs. +By default, steps cannot reach the network. A step that downloads dependencies, such as `mvn package` or `npm install` without a local cache, fails. Either use a base image that already has them, or have an admin allow the registries under **Settings > Network**. +Once that list has entries, every step gets a proxy for HTTPS: + +* The step itself still has no network. The only way out is a socket to the backend's egress proxy. Each step starts `socat` on `127.0.0.1:3128` to forward to it, and the build installs `socat` along with the other tools. +* `ALL_PROXY`, `HTTP_PROXY`, `FTP_PROXY` and `NO_PROXY` (in both cases) are cleared, and `HTTPS_PROXY` and `https_proxy` are set to `http://127.0.0.1:3128`, so `npm`, `pip`, `cargo`, `curl` and Git over HTTPS use the proxy. Java tools such as Maven and Gradle get `-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=3128` through `JAVA_TOOL_OPTIONS`. +* Only HTTPS goes through, on port 443 unless the server sets other ports in `cisys.build.network.ports`. Plain HTTP, SSH and other ports are refused. A refused connection is shown in the log as `==> Network denied: host:port (reason)`. +* The proxy allows hosts on the list only. It never connects to loopback, private, link-local or metadata addresses (such as `169.254.169.254`), the backend host's own addresses, or the database and Keycloak hosts, even when a listed name resolves to one of them. +* This works with Podman only. With Docker, a build fails with `Network access is not supported with docker` while the list has entries. + +| Entry | Allows | +|-------|--------| +| `registry.npmjs.org` | That host only | +| `*.maven.org` | Every subdomain of `maven.org`, but not `maven.org` itself | +| `151.101.0.0/16` | Public addresses in the range, when a step connects to an address directly | + +Private, loopback and link-local addresses and ranges cannot be entered. An empty list means no network. ## Private repositories @@ -206,5 +221,5 @@ The build page shows the workflow file the build read, under **Workflow**. A que * One workflow file per branch, at a fixed path. There is no trigger on push yet; builds are started from the web UI. * All jobs run one after another in a single image build. Jobs do not run in parallel and have no separate images. -* Steps have no network access, and there are no per-step timeouts, caches or artifacts. +* Steps reach the network only through the HTTPS proxy, for the hosts an admin allowed, and there are no per-step timeouts, caches or artifacts. * `dependencies` names one package per entry for the container's distribution; there is no per-distribution choice of package names in a single workflow. diff --git a/docs/manual/en/72-architecture.md b/docs/manual/en/72-architecture.md index 263e4f0..08ad1a3 100644 --- a/docs/manual/en/72-architecture.md +++ b/docs/manual/en/72-architecture.md @@ -12,6 +12,7 @@ The sources are in `src/backend/main/java/ai/mnemosyne/cisys`. * HTTP endpoints use JAX-RS via [Quarkus REST](https://quarkus.io/guides/rest): `ProjectResource` (`/api/session`, `/api/projects`), `BuildResource` (`/api/projects/{projectId}/builds`), `BrandingResource` (`/api/branding`) and `SettingsResource` (`/api/settings`). * `BuildLogs` stores each build log as one PostgreSQL large object, using JDBC and the pgjdbc `LargeObjectManager` directly rather than Hibernate. Output is appended while the build runs and streamed back from `/api/projects/{projectId}/builds/{buildId}/log`, about 1 MB per short transaction, so a log is never held in memory whole. cisys supports PostgreSQL only. * `BuildRunner` fetches repositories with [JGit](https://www.eclipse.org/jgit/) and runs builds through the Docker or Podman command line. It claims `QUEUED` builds in request order, up to the concurrency limit set under Settings or in configuration. +* `EgressProxy` is a per-build HTTPS `CONNECT` proxy on a Unix socket. Podman mounts the socket directory into each step, which still runs with `--network=none`; `socat` in the step forwards `127.0.0.1:3128` to it. The proxy allows only the hosts set under Settings → Network (`NetworkAllowlist`) on the configured ports, and never protected addresses. * `BuildRetention` is a [scheduled](https://quarkus.io/guides/scheduler) job that deletes each project's expired, unpinned builds in batches with one SQL statement per batch; the `lo_manage` trigger removes their logs. * Authentication uses [Quarkus OIDC](https://quarkus.io/guides/security-oidc-bearer-token-authentication) in `service` mode with Keycloak realm roles. diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java index 595c647..fe9535d 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java @@ -364,6 +364,88 @@ void execute(Build b) { run(b.id, b.repositoryUrl, new BuildResource.RunRequest(b.branch, b.credentialRef)); } + @Inject HostResolver resolver; + + @ConfigProperty(name = "cisys.build.network.ports", defaultValue = "443") + String networkPorts; + + @ConfigProperty(name = "quarkus.datasource.jdbc.url") + Optional datasourceUrl; + + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + Optional oidcUrl; + + @ConfigProperty(name = "cisys.keycloak.url") + Optional keycloakUrl; + + @ConfigProperty(name = "quarkus.http.host") + Optional httpHost; + + /** + * Starts the build's egress proxy in {@code dir}, or returns null when the allowlist is empty. + * Anything that keeps the proxy from enforcing its rules fails the build; a build never falls + * back to unrestricted networking. + */ + EgressProxy network( + Path dir, NetworkAllowlist allowlist, java.util.function.Consumer log) { + if (allowlist.isEmpty()) return null; + // Docker's build only forwards SSH agent sockets, so its steps cannot reach the proxy. + if (!"podman".equals(runtime)) + throw new IllegalArgumentException("Network access is not supported with " + runtime); + try { + return EgressProxy.start( + dir.resolve("proxy"), networkPolicy(allowlist), resolver, EgressProxy.TCP, log); + } catch (Exception e) { + Log.errorf(e, "Could not start the network proxy"); + throw new IllegalArgumentException("Network access could not be set up"); + } + } + + /** + * The allowlist plus the backend's own addresses and the internal services it uses, which are + * always denied. + */ + EgressProxy.Policy networkPolicy(NetworkAllowlist allowlist) throws Exception { + Set ports = new HashSet<>(); + for (String p : networkPorts.split(",")) { + if (p.isBlank()) continue; + int port = Integer.parseInt(p.strip()); + if (port < 1 || port > 65535) + throw new IllegalArgumentException("Invalid cisys.build.network.ports"); + ports.add(port); + } + Set hosts = new HashSet<>(); + for (Optional u : List.of(datasourceUrl, oidcUrl, keycloakUrl)) + u.ifPresent(v -> hosts.addAll(hostsOf(v))); + httpHost.ifPresent(h -> hosts.add(h.strip())); + hosts.removeIf(h -> h.isEmpty() || h.equals("0.0.0.0") || h.equals("::")); + Set addresses = new HashSet<>(); + for (var nic : Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) + addresses.addAll(Collections.list(nic.getInetAddresses())); + for (String h : hosts) { + java.net.InetAddress literal = NetworkAllowlist.literal(h); + addresses.addAll(literal != null ? List.of(literal) : List.of(resolver.resolve(h))); + } + return new EgressProxy.Policy(allowlist, ports, hosts, addresses); + } + + /** The host names in a URL such as {@code jdbc:postgresql://a:5432,b:5432/db}. */ + static List hostsOf(String url) { + int start = url.indexOf("//"); + if (start < 0) return List.of(); + String authority = url.substring(start + 2).split("[/?#]", 2)[0]; + authority = authority.substring(authority.lastIndexOf('@') + 1); + List hosts = new ArrayList<>(); + for (String part : authority.split(",")) { + String h = + part.startsWith("[") + ? part.substring(1, Math.max(1, part.indexOf(']'))) + : part.replaceFirst(":[0-9]*$", ""); + if (!h.isBlank()) hosts.add(h.toLowerCase(Locale.ROOT)); + } + return hosts; + } + /** For tests waiting for the queue to drain. */ boolean hasPendingBuilds() { return QuarkusTransaction.requiringNew() @@ -378,6 +460,7 @@ private void run(Long id, String url, BuildResource.RunRequest request) { boolean truncated = false; Throwable captureError = null; BuildLogs.Writer log = null; + EgressProxy proxy = null; try { log = logs.open(id); // Checked again here because DNS can change after the project was saved. @@ -427,9 +510,19 @@ private void run(Long id, String url, BuildResource.RunRequest request) { if (!QuarkusTransaction.requiringNew() .call(() -> from.names().stream().anyMatch(AllowedImage::isAllowed))) throw new IllegalArgumentException("Image " + base + " is not allowed"); + // A stored list that no longer validates fails the build rather than running it without its + // rules. + NetworkAllowlist allowlist = + QuarkusTransaction.requiringNew() + .call(() -> NetworkAllowlist.stored(Settings.current().networkAllowlist)); String containerfile = workflow.containerfile( - from.id(), url, request.branch(), revision, request.credentialRef() != null); + from.id(), + url, + request.branch(), + revision, + request.credentialRef() != null, + !allowlist.isEmpty()); log.line( "Workflow " + workflow.name() @@ -439,6 +532,8 @@ private void run(Long id, String url, BuildResource.RunRequest request) { + from.id().substring(0, 12) + ")"); dir = Files.createTempDirectory(checkoutRoot(), "cisys-"); + BuildLogs.Writer buildLog = log; + proxy = network(dir, allowlist, buildLog::line); Path context = Files.createDirectory(dir.resolve("context")); Path file = Files.writeString(dir.resolve("Containerfile"), containerfile); List build = @@ -465,6 +560,8 @@ private void run(Long id, String url, BuildResource.RunRequest request) { // the image. if (request.credentialRef() != null) build.add("--secret=id=" + Workflow.GIT_SECRET + ",env=" + request.credentialRef()); + if (proxy != null) + build.add("--volume=" + proxy.directory() + ":" + Workflow.PROXY_DIR + ":ro,Z"); build.add(context.toString()); Streamed result = stream(build, 600, log, logMaxBytes); captureError = result.readerError(); @@ -508,6 +605,12 @@ private void run(Long id, String url, BuildResource.RunRequest request) { command(List.of(runtime, "rmi", "-f", name), 60, 4096); } catch (Exception ignored) { } + if (proxy != null) + try { + proxy.close(); + } catch (Exception e) { + Log.errorf(e, "Could not stop the network proxy of build %d", id); + } if (dir != null) try { delete(dir); diff --git a/src/backend/main/java/ai/mnemosyne/cisys/EgressProxy.java b/src/backend/main/java/ai/mnemosyne/cisys/EgressProxy.java new file mode 100644 index 0000000..f486f94 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/EgressProxy.java @@ -0,0 +1,433 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.nio.channels.Channel; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * The only way out of a build step. One proxy serves one build on a Unix socket that the step + * reaches through a bind mount, while the step itself has no network. It accepts HTTP {@code + * CONNECT host:port} only and tunnels to the destination when the port and host are allowed and no + * address of the host is protected; everything else is denied and logged in the build log. + */ +final class EgressProxy implements AutoCloseable { + static final String SOCKET = "proxy.sock"; + static final int MAX_CONNECTIONS = 64; + static final int MAX_HEADER = 8192; + static final long HEADER_MILLIS = 10_000; + static final int CONNECT_MILLIS = 10_000; + static final long IDLE_MILLIS = 300_000; + + /** Opens the upstream connection. Replaced in tests, which cannot reach a public address. */ + interface Connector { + ByteChannel connect(InetSocketAddress address, int timeoutMillis) throws IOException; + } + + static final Connector TCP = + (address, timeout) -> { + SocketChannel channel = SocketChannel.open(); + try { + channel.socket().connect(address, timeout); + return channel; + } catch (IOException | RuntimeException e) { + channel.close(); + throw e; + } + }; + + /** + * What may be reached. {@code deniedHosts} and {@code deniedAddresses} are the backend's own + * interfaces and the internal services it uses; they are denied on top of {@link + * NetworkAllowlist#PROTECTED}. + */ + record Policy( + NetworkAllowlist allowlist, + Set ports, + Set deniedHosts, + Set deniedAddresses) { + Policy { + deniedHosts = + deniedHosts.stream() + .map(h -> h.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + deniedAddresses = + deniedAddresses.stream() + .map(EgressProxy::unwrapped) + .collect(Collectors.toUnmodifiableSet()); + } + } + + private final Path directory; + private final Policy policy; + private final HostResolver resolver; + private final Connector connector; + private final Consumer log; + private final ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor(); + private final ScheduledExecutorService timer = + Executors.newSingleThreadScheduledExecutor(Thread.ofVirtual().factory()); + private final Semaphore slots = new Semaphore(MAX_CONNECTIONS); + private final Set open = ConcurrentHashMap.newKeySet(); + private ServerSocketChannel server; + private boolean created; + private volatile boolean closed; + + private EgressProxy( + Path directory, + Policy policy, + HostResolver resolver, + Connector connector, + Consumer log) { + this.directory = directory; + this.policy = policy; + this.resolver = resolver; + this.connector = connector; + this.log = log; + } + + /** + * Creates {@code directory} with the socket in it and starts serving. The directory may be + * traversed but not listed by the step user; its parent must be private to the backend. On + * failure nothing is left behind. + */ + static EgressProxy start( + Path directory, + Policy policy, + HostResolver resolver, + Connector connector, + Consumer log) + throws IOException { + EgressProxy proxy = new EgressProxy(directory, policy, resolver, connector, log); + try { + proxy.listen(); + return proxy; + } catch (IOException | RuntimeException e) { + proxy.close(); + throw e; + } + } + + Path directory() { + return directory; + } + + private void listen() throws IOException { + Files.createDirectory( + directory, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + created = true; + Path socket = directory.resolve(SOCKET); + server = ServerSocketChannel.open(StandardProtocolFamily.UNIX); + server.bind(UnixDomainSocketAddress.of(socket), MAX_CONNECTIONS); + // The step's user is unrelated to the backend's, and rootless runtimes map it to a subordinate + // UID. + Files.setPosixFilePermissions(socket, PosixFilePermissions.fromString("rw-rw-rw-")); + Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwx--x--x")); + threads.submit(this::accept); + } + + private void accept() { + while (!closed) { + SocketChannel client; + try { + client = server.accept(); + } catch (IOException e) { + return; + } + if (!slots.tryAcquire()) { + reply(client, "503 Service Unavailable"); + quietly(client); + continue; + } + open.add(client); + try { + threads.submit(() -> { + try { + handle(client); + } finally { + slots.release(); + } + }); + } catch (RuntimeException e) { + slots.release(); + quietly(client); + } + } + } + + private void handle(SocketChannel client) { + ByteChannel upstream = null; + ScheduledFuture deadline = + timer.schedule(() -> quietly(client), HEADER_MILLIS, TimeUnit.MILLISECONDS); + try { + ByteBuffer header = ByteBuffer.allocate(MAX_HEADER); + int end = readHeader(client, header); + if (end < 0) { + reply(client, "400 Bad Request"); + return; + } + String request = new String(header.array(), 0, end + 2, StandardCharsets.ISO_8859_1); + String[] parts = request.substring(0, request.indexOf("\r\n")).split(" ", -1); + if (parts.length != 3 || !parts[2].startsWith("HTTP/1.")) { + reply(client, "400 Bad Request"); + return; + } + if (!parts[0].equals("CONNECT")) { + deny(client, "405 Method Not Allowed", parts[1], "only CONNECT is supported"); + return; + } + Target target = Target.parse(parts[1]); + if (target == null) { + deny(client, "400 Bad Request", parts[1], "not host:port"); + return; + } + InetAddress address; + try { + address = check(target); + } catch (Denied e) { + deny(client, e.status, parts[1], e.getMessage()); + return; + } + try { + upstream = connector.connect(new InetSocketAddress(address, target.port()), CONNECT_MILLIS); + } catch (IOException | RuntimeException e) { + deny(client, "502 Bad Gateway", parts[1], "connection failed"); + return; + } + open.add(upstream); + deadline.cancel(false); + reply(client, "200 Connection established"); + // Bytes the client sent after the request head belong to the tunnel. + ByteBuffer early = ByteBuffer.wrap(header.array(), end + 4, header.position() - end - 4); + while (early.hasRemaining()) upstream.write(early); + tunnel(client, upstream); + } catch (IOException | RuntimeException e) { + // The connection is closed below; a broken client or upstream ends only its own tunnel. + } finally { + deadline.cancel(false); + quietly(client); + if (upstream != null) quietly(upstream); + } + } + + /** + * Returns the offset of the blank line ending the request head, or -1 when there is none within + * the limit. + */ + private static int readHeader(SocketChannel client, ByteBuffer header) throws IOException { + while (header.hasRemaining()) { + if (client.read(header) < 0) return -1; + byte[] b = header.array(); + for (int i = 3; i < header.position(); i++) { + if (b[i - 3] == '\r' && b[i - 2] == '\n' && b[i - 1] == '\r' && b[i] == '\n') return i - 3; + } + } + return -1; + } + + private record Target(String host, int port) { + static Target parse(String value) { + int colon = value.lastIndexOf(':'); + if (colon <= 0 || colon == value.length() - 1) return null; + String host = value.substring(0, colon).toLowerCase(Locale.ROOT); + if (host.startsWith("[") != host.endsWith("]")) return null; + if (host.startsWith("[")) host = host.substring(1, host.length() - 1); + else if (host.contains(":")) return null; + if (host.endsWith(".")) host = host.substring(0, host.length() - 1); + if (host.isEmpty() + || host.length() > NetworkAllowlist.MAX_LENGTH + || !host.matches("[a-z0-9.:_-]+")) return null; + int port; + try { + port = Integer.parseInt(value.substring(colon + 1)); + } catch (NumberFormatException e) { + return null; + } + return port < 1 || port > 65535 ? null : new Target(host, port); + } + } + + private static final class Denied extends Exception { + final String status; + + Denied(String status, String reason) { + super(reason, null, false, false); + this.status = status; + } + } + + /** The address to connect to, checked once so a later DNS change cannot redirect the tunnel. */ + private InetAddress check(Target target) throws Denied { + if (!policy.ports().contains(target.port())) + throw new Denied("403 Forbidden", "port not allowed"); + if (!policy.allowlist().allowsHost(target.host())) + throw new Denied("403 Forbidden", "host not allowed"); + if (policy.deniedHosts().contains(target.host())) + throw new Denied("403 Forbidden", "protected destination"); + InetAddress literal = NetworkAllowlist.literal(target.host()); + InetAddress[] addresses; + if (literal != null) { + addresses = new InetAddress[] {literal}; + } else { + try { + addresses = resolver.resolve(target.host()); + } catch (UnknownHostException | RuntimeException e) { + throw new Denied("502 Bad Gateway", "could not be resolved"); + } + } + if (addresses == null || addresses.length == 0) + throw new Denied("502 Bad Gateway", "could not be resolved"); + // Every address is checked, not only the one used, so a name cannot mix a public and a + // protected address. + for (InetAddress address : addresses) { + if (address == null) throw new Denied("502 Bad Gateway", "could not be resolved"); + if (NetworkAllowlist.isProtected(address) + || policy.deniedAddresses().contains(unwrapped(address))) { + throw new Denied("403 Forbidden", "protected destination"); + } + } + return addresses[0]; + } + + private void tunnel(SocketChannel client, ByteChannel upstream) throws IOException { + AtomicLong last = new AtomicLong(System.nanoTime()); + ScheduledFuture idle = + timer.scheduleAtFixedRate( + () -> { + if (System.nanoTime() - last.get() > TimeUnit.MILLISECONDS.toNanos(IDLE_MILLIS)) { + quietly(client); + quietly(upstream); + } + }, + 1, + 1, + TimeUnit.SECONDS); + try { + var back = + threads.submit(() -> { + copy(upstream, client, last); + return null; + }); + copy(client, upstream, last); + try { + back.get(); + } catch (Exception e) { + // The other direction ended with an error; both channels are closed by the caller. + } + } finally { + idle.cancel(false); + } + } + + private static void copy(ByteChannel from, ByteChannel to, AtomicLong last) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(16384); + try { + while (from.read(buffer) >= 0) { + last.set(System.nanoTime()); + buffer.flip(); + while (buffer.hasRemaining()) to.write(buffer); + buffer.clear(); + } + } finally { + if (to instanceof SocketChannel s && s.isOpen()) { + try { + s.shutdownOutput(); + } catch (IOException ignored) { + // Already closed by the other side. + } + } + } + } + + private void deny(SocketChannel client, String status, String target, String reason) { + String shown = target.matches("[A-Za-z0-9.:\\[\\]_-]{1,300}") ? target : ""; + log.accept("==> Network denied: " + shown + " (" + reason + ")"); + reply(client, status); + } + + private static void reply(SocketChannel client, String status) { + ByteBuffer response = + ByteBuffer.wrap( + ("HTTP/1.1 " + + status + + "\r\n" + + (status.startsWith("200") ? "" : "Content-Length: 0\r\nConnection: close\r\n") + + "\r\n") + .getBytes(StandardCharsets.ISO_8859_1)); + try { + while (response.hasRemaining()) client.write(response); + } catch (IOException ignored) { + // The client went away. + } + } + + private void quietly(Channel channel) { + open.remove(channel); + try { + channel.close(); + } catch (IOException ignored) { + // Nothing more to release. + } + } + + private static InetAddress unwrapped(InetAddress address) { + try { + return InetAddress.getByAddress(RepositoryUrls.unwrap(address.getAddress())); + } catch (UnknownHostException e) { + throw new IllegalArgumentException(e); + } + } + + /** Stops serving, ends every tunnel and removes the socket and its directory. */ + @Override + public void close() { + closed = true; + if (server != null) quietly(server); + for (Channel channel : open.toArray(Channel[]::new)) quietly(channel); + threads.shutdownNow(); + timer.shutdownNow(); + try { + threads.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (!created) return; + try { + Files.deleteIfExists(directory.resolve(SOCKET)); + Files.deleteIfExists(directory); + } catch (IOException e) { + throw new IllegalStateException("Could not remove the network proxy socket", e); + } + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/NetworkAllowlist.java b/src/backend/main/java/ai/mnemosyne/cisys/NetworkAllowlist.java new file mode 100644 index 0000000..ee8c114 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/NetworkAllowlist.java @@ -0,0 +1,168 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * The destinations build steps may reach through the egress proxy, set by admins under Settings + * > Network. An entry is a host name, {@code *.suffix} for every subdomain of a name, a public + * IP address or a public CIDR range. An empty list means no network. + * + *

{@link #PROTECTED} addresses are never reachable, whatever the list says, and no entry may + * name them. + */ +record NetworkAllowlist(List entries) { + static final int MAX_ENTRIES = 100; + static final int MAX_LENGTH = 253; + + /** + * The repository blocklist plus the IPv6 forms that embed an IPv4 address (compatible, NAT64, + * 6to4, Teredo). + */ + static final List PROTECTED; + + static { + List all = new ArrayList<>(RepositoryUrls.BLOCKED_NETWORKS); + all.addAll( + RepositoryUrls.parseNetworks("::/96,64:ff9b::/96,64:ff9b:1::/48,2002::/16,2001::/32")); + PROTECTED = List.copyOf(all); + } + + private static final String LABEL = "[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"; + private static final String NAME = LABEL + "(\\." + LABEL + ")+"; + + static final NetworkAllowlist EMPTY = new NetworkAllowlist(List.of()); + + NetworkAllowlist { + entries = List.copyOf(entries); + } + + boolean isEmpty() { + return entries.isEmpty(); + } + + /** + * The stored form, one entry per line. A stored value that no longer validates fails the build. + */ + static NetworkAllowlist stored(String value) { + return value == null || value.isBlank() ? EMPTY : of(List.of(value.split("\n"))); + } + + String store() { + return entries.isEmpty() ? null : String.join("\n", entries); + } + + /** Throws {@link IllegalArgumentException} with a message safe to show to an admin. */ + static NetworkAllowlist of(List values) { + if (values == null) return EMPTY; + Set result = new LinkedHashSet<>(); + for (String value : values) { + if (value == null) continue; + String entry = value.strip().toLowerCase(Locale.ROOT); + if (entry.isEmpty()) continue; + result.add(validate(entry)); + } + if (result.size() > MAX_ENTRIES) + throw new IllegalArgumentException("At most " + MAX_ENTRIES + " network entries are allowed"); + return new NetworkAllowlist(List.copyOf(result)); + } + + private static String validate(String entry) { + if (entry.length() > MAX_LENGTH) + throw new IllegalArgumentException("Invalid network entry: " + entry.substring(0, 40) + "…"); + if (entry.contains("/")) { + RepositoryUrls.Cidr cidr; + try { + if (literal(entry.substring(0, entry.indexOf('/'))) == null) + throw new IllegalArgumentException(); + cidr = RepositoryUrls.Cidr.parse(entry); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid network entry: " + entry); + } + for (RepositoryUrls.Cidr p : PROTECTED) { + if (p.contains(cidr.network()) || cidr.contains(p.network())) throw protectedEntry(entry); + } + return entry; + } + InetAddress literal = literal(entry); + if (literal != null) { + if (isProtected(literal)) throw protectedEntry(entry); + return literal.getHostAddress(); + } + String name = entry.startsWith("*.") ? entry.substring(2) : entry; + if (!name.matches(NAME) || name.matches("[0-9.]+")) + throw new IllegalArgumentException("Invalid network entry: " + entry); + return entry; + } + + private static IllegalArgumentException protectedEntry(String entry) { + return new IllegalArgumentException("Network entry " + entry + " covers protected addresses"); + } + + /** + * Whether a CONNECT target names an allowed destination. IP literals match only IP and CIDR + * entries. + */ + boolean allowsHost(String target) { + String host = target.toLowerCase(Locale.ROOT); + if (host.endsWith(".")) host = host.substring(0, host.length() - 1); + InetAddress literal = literal(host); + if (literal != null) return allowsAddress(literal); + for (String entry : entries) { + if (entry.startsWith("*.") ? host.endsWith(entry.substring(1)) : host.equals(entry)) + return true; + } + return false; + } + + private boolean allowsAddress(InetAddress address) { + byte[] raw = RepositoryUrls.unwrap(address.getAddress()); + for (String entry : entries) { + if (entry.contains("/")) { + if (RepositoryUrls.Cidr.parse(entry).contains(raw)) return true; + } else { + InetAddress listed = literal(entry); + if (listed != null + && java.util.Arrays.equals(RepositoryUrls.unwrap(listed.getAddress()), raw)) + return true; + } + } + return false; + } + + /** + * Loopback, private, link-local, metadata and the other {@link #PROTECTED} ranges, in any + * encoding. + */ + static boolean isProtected(InetAddress address) { + byte[] raw = address.getAddress(); + return RepositoryUrls.inside(raw, PROTECTED) + || RepositoryUrls.inside(RepositoryUrls.unwrap(raw), PROTECTED); + } + + /** Parses an IP literal without any DNS lookup, or returns null for anything else. */ + static InetAddress literal(String host) { + String h = + host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host; + boolean v4 = h.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"); + boolean v6 = h.contains(":") && h.matches("[0-9a-f:.]+"); + if (!v4 && !v6) return null; + try { + return InetAddress.ofLiteral(h); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java b/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java index 3dd6168..f9ea05d 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java @@ -29,7 +29,7 @@ final class RepositoryUrls { static final String UNKNOWN = "The repository host could not be resolved"; /** Never fetched, even when the host is on the allowlist, unless the network is allowed too. */ - private static final List BLOCKED_NETWORKS = + static final List BLOCKED_NETWORKS = parseNetworks( String.join( ",", @@ -137,7 +137,7 @@ static byte[] unwrap(byte[] address) { return new byte[] {address[12], address[13], address[14], address[15]}; } - private static boolean inside(byte[] address, List networks) { + static boolean inside(byte[] address, List networks) { for (Cidr network : networks) if (network.contains(address)) return true; return false; } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Settings.java b/src/backend/main/java/ai/mnemosyne/cisys/Settings.java index d7a0e9f..e3a10e0 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Settings.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Settings.java @@ -35,6 +35,10 @@ public class Settings extends PanacheEntityBase { /** Null uses cisys.build.queue-limit. */ public Integer maxQueuedBuilds; + /** One {@link NetworkAllowlist} entry per line; null means no network. */ + @Column(columnDefinition = "text") + public String networkAllowlist; + public static Settings current() { Settings s = findById(1L); return s == null ? new Settings() : s; diff --git a/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java b/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java index 713e4e3..38df25a 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java @@ -19,7 +19,11 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Map; /** Admin settings. */ @Path("/api/settings") @@ -96,6 +100,46 @@ public Queue updateQueue(Queue input) { return new Queue(s.maxConcurrentBuilds, s.maxQueuedBuilds); } + /** Destinations build steps may reach; empty means no network. */ + public record Network(List allowedHosts) {} + + @GET + @Path("/network") + public Network network() { + requireAdmin(); + return new Network(NetworkAllowlist.stored(Settings.current().networkAllowlist).entries()); + } + + @PUT + @Path("/network") + @Transactional + public Network updateNetwork(Network input) { + requireAdmin(); + if (input == null || input.allowedHosts() == null) + throw badRequest("Allowed hosts are required"); + NetworkAllowlist allowlist; + try { + allowlist = NetworkAllowlist.of(input.allowedHosts()); + } catch (IllegalArgumentException e) { + throw badRequest(e.getMessage()); + } + Settings s = Settings.findById(1L); + if (s == null) { + s = new Settings(); + s.persist(); + } + s.networkAllowlist = allowlist.store(); + return new Network(allowlist.entries()); + } + + private static WebApplicationException badRequest(String message) { + return new WebApplicationException( + Response.status(400) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", message)) + .build()); + } + private void requireAdmin() { if (!identity.hasRole("admin")) throw new ForbiddenException(); } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java b/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java index 6263e4c..891419b 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java @@ -263,9 +263,54 @@ private static void validateDependencies(List dependencies, String w */ public String containerfile( String baseId, String repositoryUrl, String branch, String revision, boolean credential) { - return containerfile(baseId, repositoryUrl, branch, revision, credential, WAIT_SECONDS); + return containerfile(baseId, repositoryUrl, branch, revision, credential, false, WAIT_SECONDS); } + /** + * As {@link #containerfile(String, String, String, String, boolean)}, with the steps given the + * egress proxy when {@code network}. + */ + public String containerfile( + String baseId, + String repositoryUrl, + String branch, + String revision, + boolean credential, + boolean network) { + return containerfile( + baseId, repositoryUrl, branch, revision, credential, network, WAIT_SECONDS); + } + + String containerfile( + String baseId, + String repositoryUrl, + String branch, + String revision, + boolean credential, + int waitSeconds) { + return containerfile(baseId, repositoryUrl, branch, revision, credential, false, waitSeconds); + } + + /** Where the build mounts the directory holding the {@link EgressProxy} socket. */ + static final String PROXY_DIR = "/run/cisys"; + + /** The loopback port in the step where {@code socat} forwards to the proxy socket. */ + static final int PROXY_PORT = 3128; + + /** + * Starts the forwarder and points the usual proxy settings at it. The step still has no network: + * the forwarded socket is its only way out, and the proxy decides what it may reach. + */ + static final String PROXY = + """ + socat TCP-LISTEN:%1$d,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:%2$s/%3$s 2>/dev/null & + i=0; until grep -Eqs '^ *[0-9]+: 0100007F:%4$04X [0-9A-F]+:[0-9A-F]+ 0A ' /proc/net/tcp; do i=$((i+1)); if [ "$i" -ge 100 ]; then echo '==> Network proxy did not start'; exit 1; fi; sleep 0.1; done + unset ALL_PROXY all_proxy HTTP_PROXY http_proxy FTP_PROXY ftp_proxy NO_PROXY no_proxy + export HTTPS_PROXY=http://127.0.0.1:%1$d https_proxy=http://127.0.0.1:%1$d + export JAVA_TOOL_OPTIONS="-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=%1$d${JAVA_TOOL_OPTIONS:+ $JAVA_TOOL_OPTIONS}" + """ + .formatted(PROXY_PORT, PROXY_DIR, EgressProxy.SOCKET, PROXY_PORT); + /** * Builds an image from the base image, named by its ID so the build uses exactly the image that * was resolved: @@ -290,6 +335,9 @@ public String containerfile( * started them. The first failing step stops the build with its exit code. The Git token, when * there is one, is a build secret ({@value #GIT_SECRET}) that is mounted for the clone only and * is not stored in the image. + * + *

With {@code network}, {@code socat} is installed with the tools and every step starts with + * {@link #PROXY}. */ String containerfile( String baseId, @@ -297,13 +345,16 @@ String containerfile( String branch, String revision, boolean credential, + boolean network, int waitSeconds) { if (!baseId.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Not an image ID: " + baseId); StringBuilder f = new StringBuilder(); f.append("FROM ").append(baseId).append('\n'); f.append("USER 0\n"); - f.append("RUN ").append(exec("sh", "-c", INSTALL_TOOLS)).append('\n'); + f.append("RUN ") + .append(exec("sh", "-c", network ? INSTALL_NETWORK_TOOLS : INSTALL_TOOLS)) + .append('\n'); install(f, dependencies); // The variables travel as an argument, so neither the Containerfile parser nor a shell // interprets them here. @@ -333,7 +384,11 @@ String containerfile( .append('\n'); for (String run : runs(waitSeconds)) { f.append("RUN --network=none ") - .append(exec("sh", "-c", ". " + ENVIRONMENT_FILE + " || exit 1\n" + run)) + .append( + exec( + "sh", + "-c", + ". " + ENVIRONMENT_FILE + " || exit 1\n" + (network ? PROXY : "") + run)) .append('\n'); } if (f.length() > MAX_CONTAINERFILE) @@ -383,19 +438,27 @@ String containerfile( } """; - private static final String INSTALL_TOOLS = - PACKAGE_MANAGER - + """ - echo "==> Container: ${PRETTY_NAME:-unknown}, package manager: ${pm:-none}" - missing= - for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done - [ -z "$missing" ] && exit 0 - [ -n "$pm" ] || { echo '==> Cannot install%2$s: the container has no supported package manager'; exit 1; } - echo "==> Installing$missing with $pm" - install $missing || { rc=$?; echo "==> Could not install$missing"; exit "$rc"; } - for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || { echo "==> $tool is missing after the installation"; exit 1; }; done - """ - .formatted(String.join(" ", TOOLS), String.join(", ", TOOLS).replaceFirst("^", " ")); + /** {@link #TOOLS} plus {@code socat}, which forwards a step's proxy port to the proxy socket. */ + static final List NETWORK_TOOLS = + java.util.stream.Stream.concat(TOOLS.stream(), java.util.stream.Stream.of("socat")).toList(); + + private static final String INSTALL_TOOLS = installTools(TOOLS); + private static final String INSTALL_NETWORK_TOOLS = installTools(NETWORK_TOOLS); + + private static String installTools(List tools) { + return PACKAGE_MANAGER + + """ + echo "==> Container: ${PRETTY_NAME:-unknown}, package manager: ${pm:-none}" + missing= + for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done + [ -z "$missing" ] && exit 0 + [ -n "$pm" ] || { echo '==> Cannot install%2$s: the container has no supported package manager'; exit 1; } + echo "==> Installing$missing with $pm" + install $missing || { rc=$?; echo "==> Could not install$missing"; exit "$rc"; } + for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || { echo "==> $tool is missing after the installation"; exit 1; }; done + """ + .formatted(String.join(" ", tools), String.join(", ", tools).replaceFirst("^", " ")); + } /** * Arguments: name and value of each dependency, in turn. The values are package names, see {@link diff --git a/src/backend/main/resources/application.properties b/src/backend/main/resources/application.properties index b6ea70d..c6075ba 100644 --- a/src/backend/main/resources/application.properties +++ b/src/backend/main/resources/application.properties @@ -59,3 +59,6 @@ cisys.git.allowed-networks=${CISYS_GIT_ALLOWED_NETWORKS:} # Single backend runner. Limits must be positive; queue-limit counts waiting builds only. cisys.build.concurrency=${BUILD_CONCURRENCY:1} cisys.build.queue-limit=${BUILD_QUEUE_LIMIT:100} + +# Ports build steps may reach through the egress proxy, for the hosts admins allow under Settings > Network. +cisys.build.network.ports=${BUILD_NETWORK_PORTS:443} diff --git a/src/backend/main/resources/db/migration/R__schema.sql b/src/backend/main/resources/db/migration/R__schema.sql index 30c8531..25d819d 100644 --- a/src/backend/main/resources/db/migration/R__schema.sql +++ b/src/backend/main/resources/db/migration/R__schema.sql @@ -98,3 +98,5 @@ ALTER TABLE build ADD COLUMN IF NOT EXISTS workflow character varying(65536); -- Null uses the configured queue limits. ALTER TABLE settings ADD COLUMN IF NOT EXISTS maxconcurrentbuilds integer; ALTER TABLE settings ADD COLUMN IF NOT EXISTS maxqueuedbuilds integer; +-- Destinations build steps may reach through the egress proxy, one per line; null means no network. +ALTER TABLE settings ADD COLUMN IF NOT EXISTS networkallowlist text; diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildNetworkTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildNetworkTest.java new file mode 100644 index 0000000..457b46f --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildNetworkTest.java @@ -0,0 +1,123 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import static org.junit.jupiter.api.Assertions.*; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@QuarkusTest +class BuildNetworkTest { + @Inject BuildRunner runner; + @TempDir Path temp; + String runtime; + final NetworkAllowlist allowlist = NetworkAllowlist.of(List.of("registry.example.com")); + + @BeforeEach + void setup() { + runner = io.quarkus.arc.ClientProxy.unwrap(runner); + runtime = runner.runtime; + } + + @AfterEach + void cleanup() { + runner.runtime = runtime; + runner.networkPorts = "443"; + TestHostResolver.reset(); + } + + @Test + void emptyAllowlistMeansNoProxy() { + runner.runtime = "podman"; + assertNull(runner.network(temp, NetworkAllowlist.EMPTY, s -> {})); + assertFalse(Files.exists(temp.resolve("proxy"))); + } + + @Test + void dockerFailsClosed() { + runner.runtime = "docker"; + var e = + assertThrows( + IllegalArgumentException.class, () -> runner.network(temp, allowlist, s -> {})); + assertEquals("Network access is not supported with docker", e.getMessage()); + assertFalse(Files.exists(temp.resolve("proxy"))); + } + + @Test + void podmanStartsAProxyThatIsRemovedOnClose() throws Exception { + runner.runtime = "podman"; + EgressProxy proxy = runner.network(temp, allowlist, s -> {}); + try { + assertEquals(temp.resolve("proxy"), proxy.directory()); + assertTrue(Files.exists(proxy.directory().resolve(EgressProxy.SOCKET))); + } finally { + proxy.close(); + } + assertFalse(Files.exists(temp.resolve("proxy"))); + } + + @Test + void setupFailuresFailClosed() { + runner.runtime = "podman"; + runner.networkPorts = "443,70000"; + var e = + assertThrows( + IllegalArgumentException.class, () -> runner.network(temp, allowlist, s -> {})); + assertEquals("Network access could not be set up", e.getMessage()); + runner.networkPorts = "443"; + assertThrows( + IllegalArgumentException.class, + () -> runner.network(temp.resolve("missing"), allowlist, s -> {})); + assertFalse(Files.exists(temp.resolve("missing"))); + } + + @Test + void policyDeniesTheBackendAndItsServices() throws Exception { + TestHostResolver.set("db.example.com", "203.0.114.70"); + Optional configured = runner.datasourceUrl; + runner.datasourceUrl = + Optional.of("jdbc:postgresql://db.example.com:5432,[2001:db8::5]:5433/cisys"); + try { + EgressProxy.Policy policy = runner.networkPolicy(allowlist); + assertEquals(java.util.Set.of(443), policy.ports()); + assertTrue(policy.deniedHosts().contains("db.example.com"), policy.deniedHosts().toString()); + assertTrue( + policy.deniedAddresses().contains(InetAddress.getByName("203.0.114.70")), + policy.deniedAddresses().toString()); + for (var nic : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) { + for (InetAddress a : java.util.Collections.list(nic.getInetAddresses())) { + if (!(a instanceof java.net.Inet6Address)) + assertTrue(policy.deniedAddresses().contains(a), a.toString()); + } + } + } finally { + runner.datasourceUrl = configured; + } + } + + @Test + void hostsOfUrls() { + assertEquals(List.of("a", "b"), BuildRunner.hostsOf("jdbc:postgresql://a:5432,b/db?x=1")); + assertEquals( + List.of("keycloak.example.com"), + BuildRunner.hostsOf("https://user@Keycloak.example.com:8180/realms/cisys")); + assertEquals(List.of("::1"), BuildRunner.hostsOf("http://[::1]:8080")); + assertEquals(List.of(), BuildRunner.hostsOf("not a url")); + } +} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/EgressProxyTest.java b/src/backend/test/java/ai/mnemosyne/cisys/EgressProxyTest.java new file mode 100644 index 0000000..9148cfc --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/EgressProxyTest.java @@ -0,0 +1,297 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class EgressProxyTest { + @TempDir Path temp; + private final Map dns = new ConcurrentHashMap<>(); + private final List log = new CopyOnWriteArrayList<>(); + private final List connected = new CopyOnWriteArrayList<>(); + private ServerSocket echo; + private ExecutorService echoThreads; + private EgressProxy proxy; + + private final HostResolver resolver = + host -> { + String[] values = dns.get(host); + if (values == null) throw new UnknownHostException(host); + InetAddress[] result = new InetAddress[values.length]; + for (int i = 0; i < values.length; i++) result[i] = InetAddress.getByName(values[i]); + return result; + }; + + /** + * Records the checked address, then connects to the local echo server instead, as no public host + * is reachable here. + */ + private final EgressProxy.Connector connector = + (address, timeout) -> { + connected.add(address); + if (address.getAddress().getHostAddress().equals("203.0.114.99")) + throw new IOException("refused"); + SocketChannel c = SocketChannel.open(); + c.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), echo.getLocalPort())); + return c; + }; + + @BeforeEach + void setup() throws Exception { + echo = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + echoThreads = Executors.newVirtualThreadPerTaskExecutor(); + echoThreads.submit(() -> { + while (!echo.isClosed()) { + Socket s = echo.accept(); + echoThreads.submit(() -> { + try (s) { + s.getInputStream().transferTo(s.getOutputStream()); + } + return null; + }); + } + return null; + }); + dns.put("registry.example.com", new String[] {"203.0.114.10"}); + dns.put("mixed.example.com", new String[] {"203.0.114.11", "10.0.0.5"}); + dns.put("loop.example.com", new String[] {"127.0.0.1"}); + dns.put("private.example.com", new String[] {"10.1.2.3"}); + dns.put("metadata.example.com", new String[] {"169.254.169.254"}); + dns.put("mapped.example.com", new String[] {"::ffff:127.0.0.1"}); + dns.put("self.example.com", new String[] {"203.0.114.50"}); + dns.put("refused.example.com", new String[] {"203.0.114.99"}); + dns.put("db.internal.example.com", new String[] {"203.0.114.60"}); + proxy = start(Set.of(InetAddress.getByName("203.0.114.50"))); + } + + private EgressProxy start(Set denied) throws IOException { + NetworkAllowlist allowlist = + NetworkAllowlist.of(List.of("registry.example.com", "*.example.com", "8.8.8.8")); + EgressProxy.Policy policy = + new EgressProxy.Policy(allowlist, Set.of(443), Set.of("DB.internal.example.com"), denied); + return EgressProxy.start(temp.resolve("proxy"), policy, resolver, connector, log::add); + } + + @AfterEach + void cleanup() throws Exception { + proxy.close(); + echo.close(); + echoThreads.shutdownNow(); + } + + private SocketChannel open() throws IOException { + SocketChannel c = SocketChannel.open(StandardProtocolFamily.UNIX); + c.connect(UnixDomainSocketAddress.of(proxy.directory().resolve(EgressProxy.SOCKET))); + return c; + } + + private static void write(SocketChannel c, String text) throws IOException { + ByteBuffer b = ByteBuffer.wrap(text.getBytes(StandardCharsets.ISO_8859_1)); + while (b.hasRemaining()) c.write(b); + } + + /** + * Reads up to the end of the response head and returns its status line. Reads byte by byte, so + * tunnel data that arrives right behind the head is left for the next read. + */ + private static String status(SocketChannel c) throws IOException { + StringBuilder text = new StringBuilder(); + ByteBuffer b = ByteBuffer.allocate(1); + while (text.indexOf("\r\n\r\n") < 0 && text.length() < 1024) { + b.clear(); + if (c.read(b) < 0) break; + text.append((char) (b.get(0) & 0xFF)); + } + int end = text.indexOf("\r\n"); + return end < 0 ? text.toString() : text.substring(0, end); + } + + private static String read(SocketChannel c, int length) throws IOException { + ByteBuffer b = ByteBuffer.allocate(length); + while (b.hasRemaining() && c.read(b) >= 0) { + // Keep reading until the expected bytes arrived. + } + return new String(b.array(), 0, b.position(), StandardCharsets.UTF_8); + } + + private String connect(String target) throws IOException { + try (SocketChannel c = open()) { + write(c, "CONNECT " + target + " HTTP/1.1\r\nHost: " + target + "\r\n\r\n"); + return status(c); + } + } + + @Test + void tunnelsToAnAllowedHost() throws Exception { + try (SocketChannel c = open()) { + write( + c, + "CONNECT registry.example.com:443 HTTP/1.1\r\nHost: registry.example.com:443\r\n\r\nearly"); + assertEquals("HTTP/1.1 200 Connection established", status(c)); + assertEquals("early", read(c, 5), "bytes sent with the request head are forwarded"); + write(c, "hello"); + assertEquals("hello", read(c, 5)); + } + assertEquals( + List.of(new InetSocketAddress(InetAddress.getByName("203.0.114.10"), 443)), connected); + assertTrue(log.isEmpty(), log.toString()); + } + + @Test + void concurrentTunnelsStaySeparate() throws Exception { + ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); + List> results = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + String message = "message-" + i; + results.add( + pool.submit(() -> { + try (SocketChannel c = open()) { + write(c, "CONNECT registry.example.com:443 HTTP/1.1\r\n\r\n"); + assertEquals("HTTP/1.1 200 Connection established", status(c)); + write(c, message); + return read(c, message.length()); + } + })); + } + for (int i = 0; i < 20; i++) assertEquals("message-" + i, results.get(i).get()); + pool.shutdown(); + } + + @Test + void deniesWhatThePolicyDoesNotAllow() throws Exception { + assertEquals("HTTP/1.1 403 Forbidden", connect("other.org:443")); + assertEquals("HTTP/1.1 403 Forbidden", connect("registry.example.com:22")); + assertEquals("HTTP/1.1 403 Forbidden", connect("registry.example.com:80")); + assertEquals("HTTP/1.1 403 Forbidden", connect("8.8.4.4:443")); + assertEquals("HTTP/1.1 400 Bad Request", connect("registry.example.com")); + assertEquals("HTTP/1.1 400 Bad Request", connect("registry.example.com:99999")); + try (SocketChannel c = open()) { + write(c, "GET http://registry.example.com/ HTTP/1.1\r\n\r\n"); + assertEquals("HTTP/1.1 405 Method Not Allowed", status(c)); + } + assertTrue(connected.isEmpty(), connected.toString()); + assertTrue( + log.contains("==> Network denied: other.org:443 (host not allowed)"), log.toString()); + assertTrue( + log.contains("==> Network denied: registry.example.com:22 (port not allowed)"), + log.toString()); + } + + @Test + void protectedDestinationsAreAlwaysDenied() throws Exception { + for (String host : + new String[] { + "loop.example.com", + "private.example.com", + "metadata.example.com", + "mapped.example.com", + "mixed.example.com", + "self.example.com", + "db.internal.example.com", + "127.0.0.1", + "[::1]", + "169.254.169.254" + }) { + assertEquals("HTTP/1.1 403 Forbidden", connect(host + ":443"), host); + } + assertTrue(connected.isEmpty(), connected.toString()); + assertTrue( + log.contains("==> Network denied: metadata.example.com:443 (protected destination)"), + log.toString()); + } + + @Test + void failuresDenyTheRequest() throws Exception { + assertEquals("HTTP/1.1 502 Bad Gateway", connect("unknown.example.com:443")); + assertEquals("HTTP/1.1 502 Bad Gateway", connect("refused.example.com:443")); + assertTrue( + log.contains("==> Network denied: unknown.example.com:443 (could not be resolved)"), + log.toString()); + assertTrue( + log.contains("==> Network denied: refused.example.com:443 (connection failed)"), + log.toString()); + try (SocketChannel c = open()) { + write(c, "x".repeat(EgressProxy.MAX_HEADER + 10)); + assertEquals("HTTP/1.1 400 Bad Request", status(c)); + } + } + + @Test + void logsNoUntrustedText() throws Exception { + connect("evil\u001b[31m.org:443"); + assertTrue( + log.contains("==> Network denied: (not host:port)"), log.toString()); + } + + @Test + void socketIsReachableByOthersButItsDirectoryIsNotListable() throws Exception { + assertEquals( + "rwx--x--x", + PosixFilePermissions.toString(Files.getPosixFilePermissions(proxy.directory()))); + assertEquals( + "rw-rw-rw-", + PosixFilePermissions.toString( + Files.getPosixFilePermissions(proxy.directory().resolve(EgressProxy.SOCKET)))); + } + + @Test + void closeEndsTunnelsAndRemovesTheSocket() throws Exception { + SocketChannel c = open(); + write(c, "CONNECT registry.example.com:443 HTTP/1.1\r\n\r\n"); + assertEquals("HTTP/1.1 200 Connection established", status(c)); + proxy.close(); + assertEquals(-1, c.read(ByteBuffer.allocate(1))); + c.close(); + assertFalse(Files.exists(proxy.directory())); + assertThrows(IOException.class, this::open); + } + + @Test + void failedStartLeavesNothingBehindAndKeepsWhatItDidNotCreate() throws Exception { + NetworkAllowlist allowlist = NetworkAllowlist.of(List.of("registry.example.com")); + EgressProxy.Policy policy = new EgressProxy.Policy(allowlist, Set.of(443), Set.of(), Set.of()); + Path missingParent = temp.resolve("missing").resolve("proxy"); + assertThrows( + IOException.class, + () -> EgressProxy.start(missingParent, policy, resolver, connector, log::add)); + assertFalse(Files.exists(missingParent)); + Path taken = Files.createDirectory(temp.resolve("taken")); + assertThrows( + IOException.class, () -> EgressProxy.start(taken, policy, resolver, connector, log::add)); + assertTrue(Files.isDirectory(taken)); + } +} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/NetworkAllowlistTest.java b/src/backend/test/java/ai/mnemosyne/cisys/NetworkAllowlistTest.java new file mode 100644 index 0000000..a45f48a --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/NetworkAllowlistTest.java @@ -0,0 +1,167 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.InetAddress; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class NetworkAllowlistTest { + @Test + void acceptsNamesWildcardsAndPublicAddresses() { + NetworkAllowlist a = + NetworkAllowlist.of( + List.of( + " Registry.NPMjs.org ", + "*.maven.org", + "8.8.8.8", + "203.0.114.0/24", + "2606:4700::/32", + "", + "registry.npmjs.org")); + assertEquals( + List.of("registry.npmjs.org", "*.maven.org", "8.8.8.8", "203.0.114.0/24", "2606:4700::/32"), + a.entries()); + assertTrue(a.allowsHost("registry.npmjs.org")); + assertTrue(a.allowsHost("REGISTRY.npmjs.org.")); + assertTrue(a.allowsHost("repo1.maven.org")); + assertFalse(a.allowsHost("maven.org"), "a wildcard covers subdomains only"); + assertFalse(a.allowsHost("evilmaven.org")); + assertFalse(a.allowsHost("npmjs.org")); + assertTrue(a.allowsHost("8.8.8.8")); + assertTrue(a.allowsHost("203.0.114.9")); + assertTrue(a.allowsHost("[2606:4700::1]")); + assertFalse(a.allowsHost("8.8.4.4")); + } + + @Test + void ipLiteralsMatchOnlyAddressEntries() { + NetworkAllowlist a = NetworkAllowlist.of(List.of("example.com")); + assertFalse(a.allowsHost("93.184.215.14")); + } + + @Test + void rejectsMalformedEntries() { + for (String bad : + new String[] { + "localhost", + "exa mple.com", + "*.com.", + "**.example.com", + "example.com/24", + "http://example.com", + "-a.example.com", + "1.2.3", + "999.1.1.1", + "8.8.8.8/33", + "x".repeat(254) + ".com", + "*", + "*.", + "a..b" + }) { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> NetworkAllowlist.of(List.of(bad)), bad); + assertTrue(e.getMessage().startsWith("Invalid network entry"), e.getMessage()); + } + } + + @Test + void rejectsEntriesCoveringProtectedAddresses() { + for (String bad : + new String[] { + "127.0.0.1", + "10.0.0.5", + "169.254.169.254", + "192.168.1.0/24", + "10.0.0.0/8", + "0.0.0.0/0", + "8.0.0.0/4", + "::1", + "::/0", + "fd00::1", + "fe80::/10", + "::ffff:127.0.0.1", + "::ffff:10.1.2.3", + "100.64.0.1", + "64:ff9b::a00:1", + "2002:a00:1::", + "::a00:1" + }) { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> NetworkAllowlist.of(List.of(bad)), bad); + assertTrue(e.getMessage().contains("protected"), bad + ": " + e.getMessage()); + } + } + + @Test + void limitsTheNumberOfEntries() { + List many = new java.util.ArrayList<>(); + for (int i = 0; i <= NetworkAllowlist.MAX_ENTRIES; i++) many.add("h" + i + ".example.com"); + assertThrows(IllegalArgumentException.class, () -> NetworkAllowlist.of(many)); + assertEquals( + NetworkAllowlist.MAX_ENTRIES, + NetworkAllowlist.of(many.subList(0, NetworkAllowlist.MAX_ENTRIES)).entries().size()); + } + + @Test + void storedFormRoundTripsAndInvalidStoredDataFailsClosed() { + NetworkAllowlist a = NetworkAllowlist.of(List.of("a.example.com", "b.example.com")); + assertEquals("a.example.com\nb.example.com", a.store()); + assertEquals(a, NetworkAllowlist.stored(a.store())); + assertTrue(NetworkAllowlist.stored(null).isEmpty()); + assertTrue(NetworkAllowlist.stored(" ").isEmpty()); + assertNull(NetworkAllowlist.EMPTY.store()); + assertThrows( + IllegalArgumentException.class, () -> NetworkAllowlist.stored("a.example.com\n10.0.0.1")); + assertThrows( + IllegalArgumentException.class, + () -> NetworkAllowlist.of(Collections.nCopies(1, "not valid"))); + } + + @Test + void protectedAddressesInEveryEncoding() throws Exception { + for (String p : + new String[] { + "127.0.0.1", + "10.1.1.1", + "172.16.0.1", + "192.168.0.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "::1", + "::", + "fe80::1", + "fd00::1", + "::ffff:127.0.0.1", + "::7f00:1", + "64:ff9b::7f00:1", + "2002:7f00:1::", + "2001::1" + }) { + assertTrue(NetworkAllowlist.isProtected(InetAddress.getByName(p)), p); + } + for (String p : new String[] {"8.8.8.8", "140.82.112.3", "2606:4700::1111"}) { + assertFalse(NetworkAllowlist.isProtected(InetAddress.getByName(p)), p); + } + } + + @Test + void literalNeverResolvesNames() { + assertNull(NetworkAllowlist.literal("example.com")); + assertNull(NetworkAllowlist.literal("999.1.1.1")); + assertArrayEquals(new byte[] {8, 8, 8, 8}, NetworkAllowlist.literal("8.8.8.8").getAddress()); + assertEquals(16, NetworkAllowlist.literal("[2606:4700::1]").getAddress().length); + } +} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java index 7a9624e..8f8454f 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java @@ -10,6 +10,7 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertNull; import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; @@ -147,4 +148,85 @@ void rejectsInvalidQueue() { given().contentType("application/json").body(body).put(QUEUE).then().statusCode(400); } } + + private static final String NETWORK = "/api/settings/network"; + + static void clearNetwork() { + QuarkusTransaction.requiringNew() + .run(() -> Settings.update("networkAllowlist = null where id = 1")); + } + + @Test + void anonymousCannotReadNetwork() { + given().get(NETWORK).then().statusCode(401); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + void leadCannotReadOrUpdateNetwork() { + given().get(NETWORK).then().statusCode(403); + given() + .contentType("application/json") + .body("{\"allowedHosts\":[\"registry.npmjs.org\"]}") + .put(NETWORK) + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void adminSavesNetworkAllowlist() { + clearNetwork(); + given().get(NETWORK).then().statusCode(200).body("allowedHosts.size()", equalTo(0)); + try { + given() + .contentType("application/json") + .body("{\"allowedHosts\":[\" Registry.npmjs.org \",\"*.maven.org\",\"\",\"8.8.8.8\"]}") + .put(NETWORK) + .then() + .statusCode(200) + .body( + "allowedHosts", + equalTo(java.util.List.of("registry.npmjs.org", "*.maven.org", "8.8.8.8"))); + given() + .get(NETWORK) + .then() + .statusCode(200) + .body( + "allowedHosts", + equalTo(java.util.List.of("registry.npmjs.org", "*.maven.org", "8.8.8.8"))); + given() + .contentType("application/json") + .body("{\"allowedHosts\":[]}") + .put(NETWORK) + .then() + .statusCode(200) + .body("allowedHosts.size()", equalTo(0)); + assertNull(QuarkusTransaction.requiringNew().call(() -> Settings.current().networkAllowlist)); + } finally { + clearNetwork(); + } + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void rejectsInvalidOrProtectedNetworkEntries() { + clearNetwork(); + given() + .contentType("application/json") + .body("{\"allowedHosts\":[\"ok.example.com\",\"10.0.0.0/8\"]}") + .put(NETWORK) + .then() + .statusCode(400) + .body("error", equalTo("Network entry 10.0.0.0/8 covers protected addresses")); + given() + .contentType("application/json") + .body("{\"allowedHosts\":[\"not a host\"]}") + .put(NETWORK) + .then() + .statusCode(400) + .body("error", equalTo("Invalid network entry: not a host")); + given().contentType("application/json").body("{}").put(NETWORK).then().statusCode(400); + given().get(NETWORK).then().statusCode(200).body("allowedHosts.size()", equalTo(0)); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java b/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java index 88bd848..84b7932 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java @@ -467,6 +467,47 @@ void credentialIsMountedForTheCloneOnly() { assertTrue(file.contains("/run/secrets/" + Workflow.GIT_SECRET), file); } + @Test + void withoutNetworkTheContainerfileIsUnchanged() { + Workflow w = Workflow.parse(DEPENDENCIES); + String file = w.containerfile(ID, URL, "main", REV, false, false); + assertEquals(w.containerfile(ID, URL, "main", REV, false), file); + assertFalse(file.contains("socat"), file); + assertFalse(file.contains("PROXY"), file); + } + + @Test + void withNetworkStepsUseTheProxyAndStillHaveNoNetwork() { + Workflow w = Workflow.parse(VALID.replace(" wait: 8180\n", " run: make wait\n")); + List plain = w.containerfile(ID, URL, "main", REV, false).lines().toList(); + List lines = w.containerfile(ID, URL, "main", REV, false, true).lines().toList(); + assertEquals(plain.size(), lines.size()); + assertTrue( + lines.get(2).contains("for tool in git make podman podman-compose socat; do"), + lines.get(2)); + // Everything up to the steps, the clone included, is unchanged. + assertEquals(plain.subList(3, 9), lines.subList(3, 9)); + List steps = lines.subList(9, lines.size()); + String forward = + "socat TCP-LISTEN:3128,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:/run/cisys/proxy.sock"; + for (String step : steps) { + assertTrue( + step.startsWith( + "RUN --network=none [\"sh\",\"-c\",\". /etc/cisys-environment || exit 1\\n" + + forward), + step); + assertTrue( + step.contains( + "unset ALL_PROXY all_proxy HTTP_PROXY http_proxy FTP_PROXY ftp_proxy NO_PROXY no_proxy\\nexport HTTPS_PROXY=http://127.0.0.1:3128 https_proxy=http://127.0.0.1:3128"), + step); + assertTrue(step.contains("-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=3128"), step); + assertTrue(step.contains("0100007F:0C38"), step); + assertFalse( + step.contains("HTTP_PROXY=") || step.contains("http_proxy="), + "plain HTTP is not proxied: " + step); + } + } + @Test void argumentsCannotEscapeTheExecForm() { String file = diff --git a/src/frontend/src/SettingsPanel.jsx b/src/frontend/src/SettingsPanel.jsx index 0d8371a..55d8f5a 100644 --- a/src/frontend/src/SettingsPanel.jsx +++ b/src/frontend/src/SettingsPanel.jsx @@ -11,6 +11,7 @@ import { Save } from "lucide-react"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import { Label } from "./components/ui/label"; +import { Textarea } from "./components/ui/textarea"; import { Card, CardContent } from "./components/ui/card"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/ui/tabs"; import PageHeader from "./components/layout/PageHeader"; @@ -151,6 +152,91 @@ function SettingsTab({ api, section }) { ); } +function NetworkTab({ api }) { + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + useEffect(() => { + api("/settings/network") + .then((n) => setDraft(n.allowedHosts.join("\n"))) + .catch((e) => setError(e.message)); + }, [api]); + + async function submit(e) { + e.preventDefault(); + setBusy(true); + setError(""); + setNotice(""); + try { + const allowedHosts = draft + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + const saved = await api("/settings/network", { + method: "PUT", + body: JSON.stringify({ allowedHosts }), + }); + setDraft(saved.allowedHosts.join("\n")); + setNotice("Network settings saved."); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + return ( + + + {error && ( +

+ {error} +
+ )} + {notice && ( +
+ {notice} +
+ )} + {draft === null ? ( + !error &&

Loading network settings…

+ ) : ( +
+
+ +