Beast Serve --public Security Audit
Read-only audit of all files in internal/serve/ and cmd/serve/. Report generated on 2026-07-17.
Scope: visibility isolation, file permissions, runtime state, path traversal, TLS/systemd Caddy integration, publish/unpublish races, and Caddy admin API exposure.
Executive Summary
CRITICAL Public mode still exposes the file manager and S3 sidecar routes. Because the sidecar serves the entire sites directory and has no auth by default, a public deployment can list, read, and write private site content through /_files and /_s3.
Normal public site routing and the index page do filter on Site.Public, but that isolation is bypassed by the management routes.
Findings Table
| Severity | Category | Finding | Primary References |
|---|---|---|---|
| CRITICAL | Visibility Isolation | Public mode exposes unauthenticated /_files and /_s3, bypassing private/public site filtering. | internal/serve/caddy.go:40, internal/serve/caddy.go:46, internal/serve/rclone.go:180 |
| HIGH | Caddy Admin API | Public mode uses Caddy's default unauthenticated HTTP admin API on localhost:2019. | internal/serve/caddy.go:20, internal/serve/caddy.go:261 |
| HIGH | Path Traversal | Custom slugs are not validated before being used in filesystem paths and Caddyfile route text. | internal/serve/service.go:50, internal/serve/config.go:148, internal/serve/caddy.go:53 |
| HIGH | File Permissions | Public-mode deployments chmod private site directories world-readable and unpublish never revokes that access. | internal/serve/service.go:132, internal/serve/service.go:208, internal/serve/service.go:579 |
| HIGH | File Permissions | Default staging creates all serve data, site directories, and copied files world-readable before visibility is considered. | internal/serve/store.go:11, internal/serve/store.go:26, internal/serve/store.go:101 |
| MEDIUM | Publish/Unpublish | SetVisibility is a read-modify-write sequence with no lock, so concurrent publish/unpublish/deploy/remove can lose updates or reload stale routes. | internal/serve/service.go:185, internal/serve/site.go:134, internal/serve/config.go:139 |
| MEDIUM | TLS/Systemd | Public mode starts/stops the global Caddy service and loads a full replacement config, risking unrelated host config and TLS state. | internal/serve/caddy.go:205, internal/serve/caddy.go:242, internal/serve/caddy.go:261 |
| LOW | Runtime State | runtime.json is trusted without ownership/mode checks and is written non-atomically with broad read permissions. | internal/serve/config.go:185, internal/serve/config.go:191, internal/serve/config.go:194 |
1. Visibility Isolation
CRITICAL Public mode exposes file manager and S3 routes for every site
The normal public index and site route generation filter private sites, but /_files and /_s3 are always rendered into the Caddyfile. The S3 sidecar serves cfg.SitesDir(), which contains both private and public site directories, and auth is optional and empty by default.
References: internal/serve/caddy.go:40-48, internal/serve/caddy.go:100-102, internal/serve/service.go:275-287, internal/serve/service.go:311-314, internal/serve/rclone.go:177-188, cmd/serve/start.go:50-68, cmd/serve/start.go:93-95.
route /_files/*is available in public mode becauseFileManagerDiris always set.handle /_s3/*reverse-proxies to the sidecar in public mode.rclone serve s3 cfg.SitesDir()exposes the whole site store, not only public sites.- The file manager JavaScript performs
PUTuploads atinternal/serve/filemanager.go:214andinternal/serve/filemanager.go:237, so this is not just read exposure.
Impact: unauthenticated Internet clients can enumerate private site buckets, download private content, and upload or overwrite content when beast serve start --public is run without --no-s3 or --auth. Even with --auth, the file manager page remains publicly discoverable.
LOW Positive: index and normal site routes do filter private sites
The generated Caddyfile skips non-public sites in public mode, and the index page uses PublicSites() when public mode is enabled.
References: internal/serve/caddy.go:107-110, internal/serve/service.go:283-286, internal/serve/service.go:593-597, internal/serve/site.go:106-114.
This control is useful, but it is defeated by the management routes above.
2. File Permissions
HIGH makeWorldReadable() applies to private deploys in public mode and is not revoked
Deploy calls makeWorldReadable() whenever the service config is public, regardless of whether the specific site is marked public. SetVisibility makes a site world-readable on publish but does nothing on unpublish, leaving private content readable by other local users and by the Caddy system service.
References: internal/serve/service.go:132-134, internal/serve/service.go:196-210, internal/serve/service.go:579-590.
- Private deployments performed while public mode is active get recursively chmodded.
- Unpublished sites keep their relaxed mode bits.
site.jsonis included, exposing metadata such as source path and worker.- Errors from
filepath.Walkandos.Chmodare ignored, so callers may believe exposure was applied or revoked when it was not.
HIGH Private site files are world-readable by default
Directory creation uses 0755 and copied files are created with the process umask, normally 0644. This means Caddy and other local users can read private site files at the filesystem layer even before makeWorldReadable() runs.
References: internal/serve/store.go:11-13, internal/serve/store.go:26-27, internal/serve/store.go:77, internal/serve/store.go:98-101, internal/serve/site.go:175.
Impact: HTTP route filtering is the main isolation barrier. If Caddy admin config is changed, if /_s3 is exposed, or if a local user can read the tree, private content is accessible.
3. Runtime State
LOW runtime.json is trusted without integrity or ownership checks
SaveRuntime() writes runtime.json with mode 0644, and LoadRuntime() silently trusts public.enabled, domain, email, and base_url when public.enabled is true. Commands such as publish, unpublish, status, and stop load that state before selecting public/systemd behavior.
References: internal/serve/config.go:185-202, cmd/serve/publish.go:26-28, cmd/serve/publish.go:61-63, cmd/serve/status.go:25-27, cmd/serve/stop.go:28-30.
Assessment: not world-writable under the default 0755 state directory, so this is mainly same-user tampering or stale-state risk. It still matters because tampered runtime state can steer later commands into the public/systemd code path.
4. Path Traversal
HIGH Custom slug is not sanitized before path and Caddyfile use
Auto-derived slugs are sanitized by Slugify, but --slug is used directly. The slug flows into SiteDir(), SitePublicDir(), Route(), and unquoted Caddyfile route/root directives.
References: cmd/serve/deploy.go:92, internal/serve/service.go:50-53, internal/serve/service.go:79-86, internal/serve/config.go:147-152, internal/serve/slug.go:41-42, internal/serve/caddy.go:52-55.
- A slug containing path separators and
..can makeSiteDir()escapeSitesDir(). RemoveSiteDir()later callsos.RemoveAll(dir)on the derived path atinternal/serve/store.go:111-113.- Newlines, braces, spaces, or matcher syntax in manifest fields can inject malformed or attacker-chosen Caddyfile text because template fields are emitted unquoted.
Impact: a malicious or compromised local caller can write outside the intended site tree, remove escaped paths, or potentially load unintended Caddy routes after validation succeeds.
LOW Archive traversal check exists but is narrow
ZipDirectory rejects absolute and .. relative paths before writing zip entries. It is not part of public routing, and the check is broader than necessary because it rejects any filename containing ...
References: internal/serve/archive.go:53-60, internal/serve/archive_test.go:57-69.
5. TLS and Systemd Caddy Integration
MEDIUM Public mode manages the global Caddy service, not a Beast-isolated service
Public mode starts and stops systemctl caddy and loads the adapted Beast config into Caddy's admin API. A /load request replaces Caddy's active config, so unrelated Caddy-hosted sites on the same host can be disrupted. The stop path stops the whole global service.
References: internal/serve/caddy.go:201-221, internal/serve/caddy.go:239-247, internal/serve/caddy.go:250-275.
Impact: TLS certificates and Caddy app state are shared with the system service. A Beast public start/reload can unintentionally drop non-Beast routes or change global Caddy behavior.
6. Publish and Unpublish Race Conditions
MEDIUM Visibility changes are not serialized
SetVisibility loads the manifest, mutates one site, saves the manifest, saves per-site JSON, then regenerates and reloads Caddy. There is a LockPath() helper, but no lock is used by SetVisibility, Deploy, Remove, or GC.
References: internal/serve/service.go:185-214, internal/serve/service.go:45-140, internal/serve/service.go:154-182, internal/serve/gc.go:19-68, internal/serve/config.go:139-140, internal/serve/site.go:134-146.
Impact: concurrent publish/unpublish or deploy/remove operations can lose a visibility update, save stale site.json, or reload Caddy from an older manifest snapshot. The most concerning case is an unpublish racing with a publish or deploy, leaving a route public longer than intended.
7. Caddy Admin API
HIGH Public mode exposes Caddy admin on default unauthenticated loopback HTTP
Private mode sets admin unix/<socket>, but public mode omits the admin directive. The tests explicitly assert public mode should not set the custom admin socket. Caddy therefore uses its default admin endpoint, and Beast posts full configs to http://localhost:2019/load.
References: internal/serve/caddy.go:20-23, internal/serve/caddy.go:250-275, internal/serve/public_render_test.go:17-22.
Assessment: this is not directly Internet-exposed by the generated Caddyfile; it is exposed on local loopback. On a multi-user host or any environment where local SSRF to loopback is possible, an attacker can replace the Caddy config and expose arbitrary readable files or proxy targets.
Recommended Fixes
- In public mode, do not render
/_filesor/_s3by default. Require an explicit admin flag, authentication, and public-site-only scoping if these routes are needed. - Run the S3 sidecar on
127.0.0.1and serve only a filtered public directory tree, notcfg.SitesDir(). - Use private default modes: data/state dirs
0700, private site dirs0700, files0600; grant Caddy read access only to published site content. - Validate all explicit slugs with the same policy as
Slugify, reject path separators/control characters, and verify all derived paths remain underSitesDir()using cleaned absolute paths. - Quote or otherwise safely encode Caddyfile template values, and reject manifest fields that cannot be represented safely.
- Use an interprocess lock around manifest/site/Caddy regeneration operations. Make
SaveManifest,SaveRuntime, and Caddyfile writes atomic and permission-preserving. - Keep public mode on a Beast-owned Caddy instance or configure systemd Caddy admin to a protected Unix socket. Avoid unauthenticated
localhost:2019for config loads. - When unpublishing, revoke Caddy/local read access for that site's content or move it back into a private-only tree.
Verification Performed
- Read all files under
internal/serve/andcmd/serve/, including tests. - Ran
go test ./internal/serve ./cmd/serve: passed. - Confirmed tests cover public Caddy rendering basics but do not cover public-mode
/_files//_s3exposure, malicious custom slugs, runtime tampering, or concurrent visibility changes.