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 if err := ValidateAddableCapabilities(config.AddedCapabilities); err != nil {
45 return fmt.Errorf("%w: %v", ErrRuntimeGuard, err)
46 }
47 for _, bind := range config.Binds {
48 if g.isOwnSecretBind(config, bind) {
49 continue
50 }
51 if g.overlapsProtected(bind) {
52 return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
53 }
54 }
55 return nil
56}
57
58func checkGuardNetworkMode(mode string) error {
59 if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") {
60 return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode))
61 }
62 return nil
63}
64
65// guardNetworkLabel keeps the mode kind but drops a namespace path.
66func guardNetworkLabel(mode string) string {
67 if kind, _, ok := strings.Cut(mode, ":"); ok {
68 return kind + ":"
69 }
70 return mode
71}
72
73// checkGuardVolumes refuses volume names the runtime would read as host
74// paths and turn into binds.
75func checkGuardVolumes(volumes map[string]string) error {
76 for dest, name := range volumes {
77 if strings.ContainsAny(name, `/\`) {
78 return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest)
79 }
80 }
81 return nil
82}
83
84// isOwnSecretBind reports whether bind is exactly the read-only secret file
85// mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at
86// /run/secrets/<KEY>.
87func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool {
88 if g.SecretFilesDir == "" || !bind.ReadOnly {
89 return false
90 }
91 app := config.Labels[LabelApp]
92 if app == "" || config.Name == "" {
93 return false
94 }
95 key := filepath.Base(bind.Source)
96 if err := ValidateEnvKey(key); err != nil {
97 return false
98 }
99 return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) &&
100 bind.Destination == SecretFilePath(key)
101}
102
103// overlapsProtected reports whether the bind source, raw or
104// symlink-resolved, is equal to, under, or an ancestor of a protected
105// path, or of a log dir when the bind is writable. A read-only source at
106// or under a usable log dir only fails when it contains a protected path.
107func (g RuntimeGuard) overlapsProtected(bind ContainerBind) bool {
108 for _, candidate := range guardCandidates(bind.Source) {
109 readableLog := bind.ReadOnly && g.underUsableLogDir(candidate)
110 for _, protected := range g.ProtectedPaths {
111 if resolvedUnderRoot(candidate, protected) {
112 return true
113 }
114 if !readableLog && resolvedUnderRoot(protected, candidate) {
115 return true
116 }
117 }
118 if bind.ReadOnly {
119 continue
120 }
121 for _, logDir := range g.LogDirs {
122 if resolvedUnderRoot(logDir, candidate) || resolvedUnderRoot(candidate, logDir) {
123 return true
124 }
125 }
126 }
127 return false
128}
129
130// underUsableLogDir reports whether path is at or under a log dir that
131// neither is nor contains a protected path.
132func (g RuntimeGuard) underUsableLogDir(path string) bool {
133 for _, logDir := range g.LogDirs {
134 if resolvedUnderRoot(logDir, path) && !g.containsProtected(logDir) {
135 return true
136 }
137 }
138 return false
139}
140
141func (g RuntimeGuard) containsProtected(dir string) bool {
142 for _, protected := range g.ProtectedPaths {
143 if resolvedUnderRoot(dir, protected) {
144 return true
145 }
146 }
147 return false
148}
149
150func guardCandidates(source string) []string {
151 clean := filepath.Clean(source)
152 candidates := []string{clean}
153 if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
154 candidates = append(candidates, resolved)
155 }
156 return candidates
157}