internal/server/paths_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"os"
"path/filepath"
"strings"
"testing"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
)
// Review finding #8 (2026-09-21): safeAbsolutePath blocks "../" but confines
// nothing, so an admin-configured certificate path could name any absolute
// file the process can read. confinedReadPath adds the containment.
func TestConfinedReadPathConfinesToRoots(t *testing.T) {
roots := []string{"/certs", "/etc/caddy"}
for _, good := range []string{
"/certs/example.com/fullchain.pem",
"/certs",
"/etc/caddy/tls/site.crt",
" /certs//example.com/./fullchain.pem ",
} {
if _, err := confinedReadPath(good, roots); err != nil {
t.Errorf("%q should be allowed, got %v", good, err)
}
}
for _, bad := range []string{
"/etc/shadow",
"/etc/passwd",
"/data/caddyui.db",
"/certs-other/x.pem", // prefix-adjacent, not inside /certs
"/etc/caddyfile", // ditto for a file sharing the root's prefix
"/",
} {
err := mustRejectPath(t, bad, roots)
if err != nil && !strings.Contains(err.Error(), "outside") {
t.Errorf("%q: error should say the path is outside the roots, got %v", bad, err)
}
}
}
// The traversal and absolute-path rules from safeAbsolutePath still apply,
// and are reported as themselves rather than as a containment failure.
func TestConfinedReadPathKeepsSafeAbsolutePathRules(t *testing.T) {
roots := []string{"/certs"}
for _, bad := range []string{"", "certs/x.pem", "/certs/../etc/passwd", "relative/../x"} {
if _, err := confinedReadPath(bad, roots); err == nil {
t.Errorf("%q should be rejected", bad)
}
}
}
// Fails closed: no roots, or only meaningless ones, means nothing is readable.
// A root of "/" would allow every path, so it is dropped rather than honored.
func TestConfinedReadPathFailsClosedWithoutUsableRoots(t *testing.T) {
for _, roots := range [][]string{nil, {}, {"/"}, {"", " ", "relative/dir", "/etc/../"}} {
if _, err := confinedReadPath("/certs/x.pem", roots); err == nil {
t.Errorf("roots %q must not allow any path", roots)
}
}
}
// A symlink planted inside an allowed root must not become a way out of it:
// both sides are resolved before they are compared, and the resolved path is
// what the caller opens, so there is no gap between the check and the read.
func TestConfinedReadPathResolvesSymlinkEscapes(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "certs")
outside := filepath.Join(base, "secrets")
for _, dir := range []string{root, outside} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
secret := filepath.Join(outside, "shadow")
if err := os.WriteFile(secret, []byte("root:!:20000:0:99999:7:::\n"), 0o600); err != nil {
t.Fatal(err)
}
real := filepath.Join(root, "fullchain.pem")
if err := os.WriteFile(real, []byte("-----BEGIN CERTIFICATE-----\n"), 0o644); err != nil {
t.Fatal(err)
}
escape := filepath.Join(root, "escape.pem")
if err := os.Symlink(secret, escape); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if _, err := confinedReadPath(escape, []string{root}); err == nil {
t.Error("a symlink out of the root must be refused")
}
if _, err := readCertificateFile(escape, []string{root}); err == nil {
t.Error("readCertificateFile must refuse the symlink escape")
}
// A symlink that stays inside the root is fine, and so is a symlinked root.
inside := filepath.Join(root, "link.pem")
if err := os.Symlink(real, inside); err != nil {
t.Fatal(err)
}
if _, err := readCertificateFile(inside, []string{root}); err != nil {
t.Errorf("symlink within the root should be readable: %v", err)
}
linkedRoot := filepath.Join(base, "certs-link")
if err := os.Symlink(root, linkedRoot); err != nil {
t.Fatal(err)
}
if _, err := readCertificateFile(real, []string{linkedRoot}); err != nil {
t.Errorf("symlinked root should still contain its files: %v", err)
}
}
// certificateReadRoots is what the handlers actually pass: CaddyUI's own data
// directory, every node's mounted Caddy data volume, the conventional
// locations, and the operator's additions — de-duplicated, "/" dropped.
func TestCertificateReadRoots(t *testing.T) {
dir := t.TempDir()
conn, err := appdb.Open(filepath.Join(dir, "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
if _, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Primary", AdminURL: "http://127.0.0.1:2019",
Type: models.CaddyServerTypeManaged, DataDir: "/srv/caddy-data",
}); err != nil {
t.Fatal(err)
}
s := &Server{DB: conn, DBPath: filepath.Join(dir, "caddyui.db")}
roots := s.certificateReadRoots()
has := func(want string) bool {
for _, r := range roots {
if r == want {
return true
}
}
return false
}
if !has(dir) {
t.Errorf("CaddyUI's own data dir %q missing from %v", dir, roots)
}
if !has("/srv/caddy-data") {
t.Errorf("node DataDir missing from %v", roots)
}
for _, def := range defaultCertificateReadRoots {
if !has(def) {
t.Errorf("default root %q missing from %v", def, roots)
}
}
// Operator additions are honored; "/" and junk are not.
if err := models.SetSetting(conn, settingCertificateReadRoots, "/opt/tls\n/mnt/certs, /opt/tls\n/\n \nnot-absolute"); err != nil {
t.Fatal(err)
}
roots = s.certificateReadRoots()
for _, want := range []string{"/opt/tls", "/mnt/certs"} {
if !has(want) {
t.Errorf("configured root %q missing from %v", want, roots)
}
}
seen := map[string]int{}
for _, r := range roots {
seen[r]++
if r == "/" || !filepath.IsAbs(r) {
t.Errorf("unusable root %q kept in %v", r, roots)
}
}
if seen["/opt/tls"] != 1 {
t.Errorf("duplicate root not collapsed: %v", roots)
}
if _, err := confinedReadPath("/opt/tls/site.pem", roots); err != nil {
t.Errorf("configured root should allow its files: %v", err)
}
if _, err := confinedReadPath("/etc/shadow", roots); err == nil {
t.Error("/etc/shadow must stay unreadable with a realistic root set")
}
}
func mustRejectPath(t *testing.T, p string, roots []string) error {
t.Helper()
got, err := confinedReadPath(p, roots)
if err == nil {
t.Errorf("%q should be refused, got %q", p, got)
}
return err
}