Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,96 @@
# Changelog

## [0.31.0](https://github.com/solisoft/soli-proxy/compare/v0.30.0...v0.31.0) (2026-09-04)

### Security

* **Request paths with dot segments are rejected before routing.** Rules match on the raw
path and per-route auth binds to the matched rule, so `/api/../admin/users` could pass an
open `/api/` rule and land on `/admin/users` at any backend that normalises. Literal and
`%2e`-encoded dots are caught, terminated by `/`, end of path, a `;` path parameter
(`/api/..;/admin`, as Tomcat/Jetty/Spring strip it) or a backslash (`..\`, as IIS treats
it). An encoded slash (`%2F`) anywhere is rejected as well, since a backend that decodes it
before routing would see a path the proxy never matched. Backends whose API paths carry
`%2F` as data (GitLab's `group%2Fproject`, S3-style keys) can set
`[server] allow_encoded_slash = true`; `..%2F` and `%2F..` stay rejected.
* **`docker_network` is validated.** The value went straight to `docker run --network`, so
`docker_network = "host"` bypassed the namespace denylist that only looked at
`docker_options`. `host` and `container:<id>` are refused in every mode, the name must be
one docker accepts, and the manifest is fully validated before the network is created, so
a rejected deploy no longer leaves a tenant-named network behind.
* **The single-tenant `docker_options` denylist reads docker's syntax.** It split on `=` and
whitespace and inspected the next token, so `-v/:/host`, `--mount type=bind,source=/`,
`/./:/host`, `--pid container:x`, `--volumes-from`, `--env-file` and `--group-add` all
passed. Flags are now parsed the way docker parses them (attached shorthand, `--mount`
key=value specs), mount sources are normalised and canonicalised before the root / docker
socket check, and the namespace, volumes-from, env-file and group-add flags are on the list.
* **Multi-tenant bind mounts may only be the site directory itself, emitted canonicalised.**
A sub-path such as `<site>/data` was validated by canonicalising it, but the tenant's raw
token reached `docker run`, which resolves the path again at mount time — and every
component under the site directory is writable by the tenant's still-running previous slot,
which could swap `data` for a symlink to `/` in between. The site directory's own path has
no tenant-writable component; it is the only permitted source, and its canonical path is
what reaches docker.
* **`PUT /api/v1/config` pairs auth hashes by matcher, not index.** A `hash: ""` entry (the
API never returns hashes) was resolved against whichever old rule sat at the same index, so
deleting or reordering rules handed a route the password of another (same username) or
rejected the change with 400 (different username).
* **Empty admin credentials count as unset.** `[admin] api_key = ""` (a templated config with
an unresolved variable) made the server log "no authentication configured" and then 401
every request, and `ADMIN_USER="" ADMIN_PASSWORD=""` was hashed into a credential that
`Authorization: Basic Og==` satisfied. Empty strings are dropped at load time.
* **Admin mutations need `X-Requested-With`.** Any non-GET request without `X-Api-Key` must
carry an `X-Requested-With` header of any value, or it is answered 403. An HTML form cannot
set it, which is what stops a page the operator visits from driving the API with cached
Basic credentials or the open loopback default. A bare `curl -X POST` against loopback
needs `-H X-Requested-With:curl` now.

### Changed

* **`base64.decode` in Lua returns `nil, err` on malformed input instead of raising.** Hook
errors now fail closed (500 "script error"), so a raise on an attacker-controlled
`Authorization` header would have turned every malformed credential into a 500. Scripts
written against the old contract (`pcall(base64.decode, s)`) keep working for valid input,
but the failure branch must change to check the return value:

```lua
local decoded = base64.decode(token)
if not decoded then return req:deny(401, "Malformed credentials") end
```

The bundled `scripts/lua/auth.lua` is updated.
* **`name` and `domain` in `app.infos` are validated in every mode.** Hostname characters
plus `_` (an existing `sites/my_app.example.com` keeps loading), a leading `_` only for
bundled apps, and `health_check` must be an absolute URL path. A directory whose manifest
fails is skipped and logged at warn level.
* **The environment allowlist reaches Docker apps too.** `HTTP(S)_PROXY`/`NO_PROXY`,
`SOLI_RELEASE_BASE_URL` and `SOLI_NO_PIN` are passed as `-e` flags into the container;
the host-path entries (`XDG_CACHE_HOME`, `SSL_CERT_FILE`, `SSL_CERT_DIR`) are native-only.

### Fixed

* **Apps got the proxy's `HOME`, not their own.** The proxy drops privileges to
the app's `user` but handed the child the environment variable it inherited
itself — `/root` under systemd. Every `~`-resolved path therefore pointed at a
directory the app could not read, silently breaking soli's package cache
(`~/.soli/packages`), its registry credentials and the Tailwind CLI it
downloads to `~/.soli/bin`. `HOME` is now read from the passwd entry of the
user the app actually runs as.

### Added

* **A short environment allowlist survives `env_clear()`.** Apps still start
with a cleared environment, but `XDG_CACHE_HOME`, `SOLI_RELEASE_BASE_URL`,
`SOLI_NO_PIN`, the `HTTP(S)_PROXY`/`NO_PROXY` family and `SSL_CERT_FILE` /
`SSL_CERT_DIR` now pass through when set on the proxy. Without them an app
behind an egress proxy could not make outbound HTTPS requests, and could not
be pointed at a shared cache.

Together these let a Soli app pin its interpreter version
(`soli_version = "=2.0.3"` in `soli.toml`) and have the proxy start it on that
version. No proxy configuration is needed — the app already starts with its
own directory as the working directory, which is where soli looks for the pin.

## [0.29.2](https://github.com/solisoft/soli-proxy/compare/v0.29.1...v0.29.2) (2026-07-29)

Website and admin UI only — the proxy binary is unchanged from 0.29.1.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "soli-proxy"
version = "0.30.0"
version = "0.31.0"
edition = "2021"
description = "A fast, configurable reverse proxy with automatic HTTPS, Lua scripting, and blue-green deployments"
license = "MIT"
Expand Down
99 changes: 94 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ soli-proxy update [--reinstall] # Self-update from GitH
bind = "0.0.0.0:8080"
https_port = 8443
worker_threads = "auto"
# Paths with a dot segment (`/api/../admin`, `%2e%2e`, `..;`, `..\`) are answered 400 before
# any rule matches. So is an encoded slash (`%2F`) anywhere, unless the backend needs them as
# data (GitLab's `group%2Fproject`, S3-style keys); `..%2F` stays rejected either way.
allow_encoded_slash = false

[tls]
mode = "auto" # "auto" for dev, "letsencrypt" for production
Expand Down Expand Up @@ -332,8 +336,63 @@ port_range_end = 30000
| `user` | string | `[apps].default_user` from `config.toml` | OS user to drop privileges to (required when running the proxy as root). |
| `group` | string | `[apps].default_group` from `config.toml` | OS group to drop privileges to. |
| `docker_image` | string | _none_ | If set, the app runs inside Docker using this image instead of a host process. |
| `docker_options` | string | _none_ | Extra flags appended to `docker run` (validated against an allowlist). |
| `docker_network` | string | `"soli-apps"` | Docker network the container joins (created automatically if missing). |
| `docker_options` | string | _none_ | Extra flags appended to `docker run`. Whitespace-split, no shell. Single-tenant: a denylist rejects `--privileged`, `--cap-add`, `--device`, `--security-opt`, `--userns`, `--volumes-from`, `--env-file`, `--group-add`, joining the `host` or another container's namespaces, and docker-socket / root mounts in every spelling (`-v/:/x`, `--mount type=bind,source=/`, `/./`, `/etc/..`). Multi-tenant: only the allowlist below is accepted. |
| `docker_network` | string | `"soli-apps"` | Docker network the container joins (created automatically if missing). A plain network name only: `host` and `container:<id>` are refused in every mode, since the value goes straight to `--network`. |

### The app's environment

An app is started with a **cleared environment**, so nothing the proxy happens
to inherit leaks into it. The child gets:

| Variable | Value |
|---|---|
| `PORT` | The blue/green slot's port. |
| `WORKERS` | The `workers` setting. |
| `HOME` | **The home directory of the `user` the app runs as**, read from the passwd database — not the proxy's own. |
| `PATH`, `LANG`, `TZ` | Copied from the proxy. |

`HOME` matters more than it looks. The proxy usually runs as root and drops
privileges to the app's `user`, so handing the child the proxy's own `HOME`
(`/root` under systemd) pointed every `~`-resolved path at a directory the app
cannot read. That silently broke soli's package cache (`~/.soli/packages`), its
registry credentials, the Tailwind CLI it downloads to `~/.soli/bin`, and the
cache for [pinned interpreter versions](https://soli.solisoft.net/docs/language/modules).

A short allowlist also survives the clear, when it is set on the proxy:

| Variable | Why |
|---|---|
| `XDG_CACHE_HOME` | Points at a shared soli toolchain cache, so a pinned app does not download its interpreter on the server and several apps running as different users can share one. |
| `SOLI_RELEASE_BASE_URL` | An internal mirror for those downloads. |
| `SOLI_NO_PIN` | Operator override for a version pin, e.g. during an incident. |
| `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (and lowercase) | Outbound egress proxy. |
| `SSL_CERT_FILE`, `SSL_CERT_DIR` | Custom CA bundle. |

Anything else stays cleared. Put per-app configuration in the app's own `.env`,
not in the proxy's environment.

A Docker app gets the same treatment, minus the entries that name a host path: the proxy-family
variables, `SOLI_RELEASE_BASE_URL` and `SOLI_NO_PIN` are passed as `-e` flags into the
container, while `XDG_CACHE_HOME`, `SSL_CERT_FILE` and `SSL_CERT_DIR` are not (the container
cannot see those directories; bake a CA bundle into the image instead).

### Pinned Soli versions

A Soli app can pin the exact interpreter it runs on, with
`soli_version = "=2.0.3"` in its `soli.toml`. The proxy needs no configuration
for this: it starts an app with the app directory as the working directory, and
soli resolves the pin from there — the same as on a developer machine.

Two things to get right on a server:

- **Provision the toolchain during deployment, not at start-up.** A new instance
has 30 seconds to pass its health check. A first start after changing a pin
spends part of that window downloading, and a slow link can push it over; the
deploy then fails and succeeds on the retry, once the cache is warm.
- **Make the cache readable by the app's user.** With `HOME` now resolved
correctly this works by default, but several apps running as different users
will each download their own copy. Point `XDG_CACHE_HOME` at a shared
directory readable by all of them to avoid that.

### Variable substitution

Expand Down Expand Up @@ -380,8 +439,32 @@ With it on:
- Every container gets `--read-only`, a `noexec,nosuid` tmpfs at `/tmp`, `--cap-drop ALL`,
`--security-opt no-new-privileges`, `--pids-limit 256`, plus the memory/cpu/user limits above.
- These are appended **after** the app's own `docker_options`, and `docker run` honours the last
occurrence of a repeated flag, so an app cannot raise its own ceiling or run as root.
- `docker_options` is still validated (no `--privileged`, no host namespaces, no docker socket).
occurrence of a repeated flag, so an app cannot raise its own ceiling or run as root. The image
is passed after a `--` terminator, and `docker_image` must be a well-formed image reference, so
neither it nor the start script can smuggle in further flags.
- `docker_options` is validated against an **allowlist** — anything not listed fails the deploy,
naming the offending token. Every flag must carry a value (a trailing flag would swallow the
platform's hardening). Permitted:
- `-e`/`--env KEY=VALUE`, `-l`/`--label`, `--restart`, `--stop-timeout`, `--health-*`
- `-m`/`--memory`, `--cpus`, `--cpu-shares`, `--pids-limit`, `--shm-size` (the platform's
limits still win, see above)
- no `-p`/`--publish`: the proxy publishes the allocated slot port as `127.0.0.1:$PORT:$PORT`
itself, so a tenant cannot bind a host port that belongs to another tenant's slot
- `-v`/`--volume SRC:DST[:ro|rw]` and `--mount type=bind,source=SRC,target=DST[,readonly]`
only when `SRC` canonicalises (symlinks resolved) to the app's own site directory — the
directory itself, not a path inside it. Everything under the site directory is writable by
the tenant's running container, which could swap a sub-directory for a symlink between the
check and docker's own path resolution at mount time; the site directory's own path has no
tenant-writable component. The canonical path is what reaches `docker run`, never the
tenant's spelling. Named volumes, other mount types, propagation and relabel options are
rejected.
- `name` and `domain` in `app.infos` are bound to the site directory: `name` must equal it,
`domain` must be it or its `www.` twin (or empty). A tenant cannot claim another site's `Host`
or take over another app's entry; a directory whose manifest breaks the rule is skipped and
logged. Names starting with `_` are reserved for bundled apps (`_admin`) in every mode.
- `name`, `domain` and `health_check` are checked at load time in every mode: hostname
characters (plus `_`, for existing `my_app.example.com` directories) for the first two, an
absolute URL path for the third.

> Docker has a long history of container escapes. This raises the cost of one; it is not a VM
> boundary. For genuinely hostile code, treat it as the first step toward gVisor or Firecracker.
Expand Down Expand Up @@ -416,9 +499,15 @@ curl -X POST http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases \
-d '{"domain":"www.example.com"}'

curl http://127.0.0.1:9090/api/v1/aliases # domain -> app
curl -X DELETE http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases/www.example.com
curl -X DELETE http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases/www.example.com \
-H "X-Api-Key: $KEY"
```

Every non-GET request must carry `X-Api-Key`, or — when no key is configured, or with Basic
auth — an `X-Requested-With` header of any value. That is the CSRF guard: an HTML form cannot
set either header, so a page the operator happens to visit cannot drive the admin API with
the browser's cached credentials or the open loopback default.

Rollback is the same POST with a different app: send `{"domain":"www.example.com"}` to the
previous deployment and traffic moves back, with both processes left running.

Expand Down
5 changes: 5 additions & 0 deletions scripts/lua/auth.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ function on_request(req)
return req:deny(401, "Unsupported auth scheme")
end

-- base64.decode returns nil, err on malformed input; never let a bad
-- header raise past this point (an error would abort the hook).
local decoded = base64.decode(encoded)
if not decoded then
return req:deny(401, "Malformed credentials")
end
local user, pass = decoded:match("^([^:]+):(.+)$")
if not user or not pass then
return req:deny(401, "Malformed credentials")
Expand Down
Loading
Loading