1package domain
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7)
8
9// RuntimeGuard is the last check on every app container config before it
10// reaches the runtime. It refuses configs that would give a workload
11// access to the host or to Gordon itself, whatever produced them.
12//
13// The zero value still refuses host/shared network namespaces and host
14// paths passed as volume names; ProtectedPaths adds the bind checks.
15type RuntimeGuard struct {
16 // ProtectedPaths are absolute host paths no bind may equal, lie under,
17 // or contain (data dir, config, runtime sockets, key stores).
18 ProtectedPaths []string
19 // SecretFilesDir is the host base directory of file-delivered secrets
20 // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
21 // it are the only allowed exception.
22 SecretFilesDir string
23 // LogDirs are Gordon's log directories. Binds overlapping them are
24 // refused unless read-only; a read-only bind at or under one is
25 // allowed even when a protected path (the data dir) contains it, so
26 // a log shipper can read them. A log dir that is or contains a
27 // protected path grants no exception.
28 LogDirs []string
29}
30
31// Check refuses a config that breaks the guard. Errors wrap
32// ErrRuntimeGuard and name the bind, volume destination, or network mode,
33// never a host path.
34func (g RuntimeGuard) Check(config *ContainerConfig) error {
35 if err := checkGuardNetworkMode(config.NetworkMode); err != nil {
36 return err
37 }
38 if err := checkGuardVolumes(config.Volumes); err != nil {
39 return err
40 }
41 if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
42 return err
43 }
44 for _, bind := range config.Binds {
45 if g.isOwnSecretBind(config, bind) {
46 continue
47 }
48 if g.overlapsProtected(bind) {
49 return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
50 }
51 }
52 return nil
53}
54
55func checkGuardNetworkMode(mode string) error {
56 if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") {
57 return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode))
58 }
59 return nil
60}
61
62// guardNetworkLabel keeps the mode kind but drops a namespace path.
63func guardNetworkLabel(mode string) string {
64 if kind, _, ok := strings.Cut(mode, ":"); ok {
65 return kind + ":"
66 }
67 return mode
68}
69
70// checkGuardVolumes refuses volume names the runtime would read as host
71// paths and turn into binds.
72func checkGuardVolumes(volumes map[string]string) error {
73 for dest, name := range volumes {
74 if strings.ContainsAny(name, `/\`) {
75 return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest)
76 }
77 }
78 return nil
79}
80
81// isOwnSecretBind reports whether bind is exactly the read-only secret file
82// mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at
83// /run/secrets/<KEY>.
84func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool {
85 if g.SecretFilesDir == "" || !bind.ReadOnly {
86 return false
87 }
88 app := config.Labels[LabelApp]
89 if app == "" || config.Name == "" {
90 return false
91 }
92 key := filepath.Base(bind.Source)
93 if err := ValidateEnvKey(key); err != nil {
94 return false
95 }
96 return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) &&
97 bind.Destination == SecretFilePath(key)
98}
99
100// overlapsProtected reports whether the bind source, raw or
101// symlink-resolved, is equal to, under, or an ancestor of a protected
102// path, or of a log dir when the bind is writable. A read-only source at
103// or under a usable log dir only fails when it contains a protected path.
104func (g RuntimeGuard) overlapsProtected(bind ContainerBind) bool {
105 for _, candidate := range guardCandidates(bind.Source) {
106 readableLog := bind.ReadOnly && g.underUsableLogDir(candidate)
107 for _, protected := range g.ProtectedPaths {
108 if resolvedUnderRoot(candidate, protected) {
109 return true
110 }
111 if !readableLog && resolvedUnderRoot(protected, candidate) {
112 return true
113 }
114 }
115 if bind.ReadOnly {
116 continue
117 }
118 for _, logDir := range g.LogDirs {
119 if resolvedUnderRoot(logDir, candidate) || resolvedUnderRoot(candidate, logDir) {
120 return true
121 }
122 }
123 }
124 return false
125}
126
127// underUsableLogDir reports whether path is at or under a log dir that
128// neither is nor contains a protected path.
129func (g RuntimeGuard) underUsableLogDir(path string) bool {
130 for _, logDir := range g.LogDirs {
131 if resolvedUnderRoot(logDir, path) && !g.containsProtected(logDir) {
132 return true
133 }
134 }
135 return false
136}
137
138func (g RuntimeGuard) containsProtected(dir string) bool {
139 for _, protected := range g.ProtectedPaths {
140 if resolvedUnderRoot(dir, protected) {
141 return true
142 }
143 }
144 return false
145}
146
147func guardCandidates(source string) []string {
148 clean := filepath.Clean(source)
149 candidates := []string{clean}
150 if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
151 candidates = append(candidates, resolved)
152 }
153 return candidates
154}