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.

8Findings
1Critical
4High
2Medium

Findings Table

SeverityCategoryFindingPrimary References
CRITICALVisibility IsolationPublic 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
HIGHCaddy Admin APIPublic mode uses Caddy's default unauthenticated HTTP admin API on localhost:2019.internal/serve/caddy.go:20, internal/serve/caddy.go:261
HIGHPath TraversalCustom 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
HIGHFile PermissionsPublic-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
HIGHFile PermissionsDefault 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
MEDIUMPublish/UnpublishSetVisibility 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
MEDIUMTLS/SystemdPublic 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
LOWRuntime Stateruntime.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 because FileManagerDir is 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 PUT uploads at internal/serve/filemanager.go:214 and internal/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.json is included, exposing metadata such as source path and worker.
  • Errors from filepath.Walk and os.Chmod are 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 make SiteDir() escape SitesDir().
  • RemoveSiteDir() later calls os.RemoveAll(dir) on the derived path at internal/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

Verification Performed