1package domain
2
3import (
4 "net"
5 "os"
6 "path/filepath"
7 "syscall"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14func validPolicy(root, source string) AppBindPolicy {
15 return AppBindPolicy{
16 Name: "config",
17 Source: source,
18 AllowedApps: []string{"blog"},
19 AllowedServices: []string{"web"},
20 Root: root,
21 }
22}
23
24func TestAppBindPolicy_Validate(t *testing.T) {
25 root := t.TempDir()
26 source := filepath.Join(root, "binds")
27 require.NoError(t, os.MkdirAll(source, 0o755))
28
29 require.NoError(t, validPolicy(root, source).Validate())
30
31 cases := []struct {
32 name string
33 mutate func(*AppBindPolicy)
34 wantErr string
35 }{
36 {"empty name", func(p *AppBindPolicy) { p.Name = "" }, "policy name"},
37 {"bad name", func(p *AppBindPolicy) { p.Name = "Bad" }, "policy name"},
38 {"reserved name", func(p *AppBindPolicy) { p.Name = "a--b" }, "reserved separator"},
39 {"relative source", func(p *AppBindPolicy) { p.Source = "binds" }, "must be absolute"},
40 {"unclean source", func(p *AppBindPolicy) { p.Source = root + "/binds/../etc" }, "normalized clean path"},
41 {"empty source", func(p *AppBindPolicy) { p.Source = "" }, "source must not be empty"},
42 {"relative root", func(p *AppBindPolicy) { p.Root = "root" }, "must be absolute"},
43 {"unclean root", func(p *AppBindPolicy) { p.Root = root + "/x/.." }, "normalized clean path"},
44 {"empty allowed apps", func(p *AppBindPolicy) { p.AllowedApps = nil }, "allowed apps must not be empty"},
45 {"empty allowed services", func(p *AppBindPolicy) { p.AllowedServices = nil }, "allowed services must not be empty"},
46 {"bad allowed app", func(p *AppBindPolicy) { p.AllowedApps = []string{"Bad!"} }, "allowed app"},
47 {"bad allowed service", func(p *AppBindPolicy) { p.AllowedServices = []string{"Bad!"} }, "allowed service"},
48 {"duplicate allowed app", func(p *AppBindPolicy) { p.AllowedApps = []string{"blog", "blog"} }, "duplicated"},
49 {"duplicate allowed service", func(p *AppBindPolicy) { p.AllowedServices = []string{"web", "web"} }, "duplicated"},
50 }
51 for _, tc := range cases {
52 t.Run(tc.name, func(t *testing.T) {
53 policy := validPolicy(root, source)
54 tc.mutate(&policy)
55 err := policy.Validate()
56 require.Error(t, err)
57 assert.ErrorIs(t, err, ErrBindPolicy)
58 assert.Contains(t, err.Error(), tc.wantErr)
59 })
60 }
61}
62
63func TestAppBindPolicy_ResolveAppBind(t *testing.T) {
64 root := t.TempDir()
65 source := filepath.Join(root, "binds", "config")
66 require.NoError(t, os.MkdirAll(source, 0o755))
67
68 policy := validPolicy(root, source)
69 resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
70 require.NoError(t, err)
71 assert.Equal(t, "config", resolved.Name)
72 assert.Equal(t, "/etc/app.conf", resolved.Destination)
73 assert.False(t, resolved.ReadOnly)
74 realSource, err := filepath.EvalSymlinks(source)
75 require.NoError(t, err)
76 assert.Equal(t, realSource, resolved.Source)
77}
78
79func TestAppBindPolicy_ResolveAppBindReadOnlyNonWeakening(t *testing.T) {
80 root := t.TempDir()
81 source := filepath.Join(root, "file.txt")
82 require.NoError(t, os.WriteFile(source, []byte("data"), 0o644))
83
84 cases := []struct {
85 name string
86 policyRO bool
87 bindRO bool
88 wantReadOnly bool
89 }{
90 {"policy enforces read-only", true, false, true},
91 {"bind requests read-only", false, true, true},
92 {"both read-only", true, true, true},
93 {"writable by default", false, false, false},
94 }
95 for _, tc := range cases {
96 t.Run(tc.name, func(t *testing.T) {
97 policy := validPolicy(root, source)
98 policy.ReadOnly = tc.policyRO
99 resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: tc.bindRO})
100 require.NoError(t, err)
101 assert.Equal(t, tc.wantReadOnly, resolved.ReadOnly)
102 })
103 }
104}
105
106func TestAppBindPolicy_ResolveAppBindAllowlists(t *testing.T) {
107 root := t.TempDir()
108 source := filepath.Join(root, "binds")
109 require.NoError(t, os.MkdirAll(source, 0o755))
110 policy := validPolicy(root, source)
111
112 _, err := policy.ResolveAppBind("other", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
113 require.ErrorIs(t, err, ErrBindPolicy)
114 assert.Contains(t, err.Error(), "does not allow app")
115
116 _, err = policy.ResolveAppBind("blog", "other", AppBind{Name: "config", Path: "/etc/app.conf"})
117 require.ErrorIs(t, err, ErrBindPolicy)
118 assert.Contains(t, err.Error(), "does not allow service")
119
120 // Exact, not prefix, matching.
121 _, err = policy.ResolveAppBind("blog-staging", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
122 require.ErrorIs(t, err, ErrBindPolicy)
123}
124
125func TestAppBindPolicy_ResolveAppBindBindShape(t *testing.T) {
126 root := t.TempDir()
127 source := filepath.Join(root, "binds")
128 require.NoError(t, os.MkdirAll(source, 0o755))
129 policy := validPolicy(root, source)
130
131 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "Bad", Path: "/etc/app.conf"})
132 require.ErrorIs(t, err, ErrInvalidAppSpec)
133
134 _, err = policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "relative"})
135 require.ErrorIs(t, err, ErrInvalidAppSpec)
136}
137
138func TestAppBindPolicy_ResolveAppBindRootBoundary(t *testing.T) {
139 root := t.TempDir()
140 outside := t.TempDir()
141 require.NoError(t, os.MkdirAll(filepath.Join(root, "binds"), 0o755))
142 require.NoError(t, os.MkdirAll(filepath.Join(outside, "binds"), 0o755))
143
144 t.Run("source outside root", func(t *testing.T) {
145 policy := validPolicy(root, filepath.Join(outside, "binds"))
146 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
147 require.ErrorIs(t, err, ErrBindPolicy)
148 assert.Contains(t, err.Error(), "outside root")
149 })
150
151 t.Run("sibling prefix is not inside", func(t *testing.T) {
152 // root/../root-sibling must not be treated as a descendant of root.
153 sibling := root + "-sibling"
154 require.NoError(t, os.MkdirAll(sibling, 0o755))
155 t.Cleanup(func() { _ = os.RemoveAll(sibling) })
156 policy := validPolicy(root, sibling)
157 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
158 require.ErrorIs(t, err, ErrBindPolicy)
159 assert.Contains(t, err.Error(), "outside root")
160 })
161
162 t.Run("symlink escaping root is refused", func(t *testing.T) {
163 link := filepath.Join(root, "escape")
164 require.NoError(t, os.Symlink(filepath.Join(outside, "binds"), link))
165 policy := validPolicy(root, link)
166 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
167 require.ErrorIs(t, err, ErrBindPolicy)
168 assert.Contains(t, err.Error(), "outside root")
169 })
170
171 t.Run("symlink inside root resolves", func(t *testing.T) {
172 real := filepath.Join(root, "binds")
173 link := filepath.Join(root, "link")
174 require.NoError(t, os.Symlink(real, link))
175 policy := validPolicy(root, link)
176 resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
177 require.NoError(t, err)
178 realSource, err := filepath.EvalSymlinks(real)
179 require.NoError(t, err)
180 assert.Equal(t, realSource, resolved.Source)
181 })
182
183 t.Run("root itself is inside", func(t *testing.T) {
184 policy := validPolicy(root, root)
185 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
186 require.NoError(t, err)
187 })
188
189 t.Run("optional root is not required", func(t *testing.T) {
190 policy := validPolicy("", filepath.Join(outside, "binds"))
191 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
192 require.NoError(t, err)
193 })
194}
195
196func TestAppBindPolicy_ResolveAppBindSourceTypes(t *testing.T) {
197 root := t.TempDir()
198 require.NoError(t, os.MkdirAll(filepath.Join(root, "binds"), 0o755))
199
200 t.Run("missing source", func(t *testing.T) {
201 policy := validPolicy(root, filepath.Join(root, "binds", "nope"))
202 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
203 require.ErrorIs(t, err, ErrBindPolicy)
204 })
205
206 t.Run("fifo refused", func(t *testing.T) {
207 fifo := filepath.Join(root, "binds", "fifo")
208 require.NoError(t, syscall.Mkfifo(fifo, 0o600))
209 policy := validPolicy(root, fifo)
210 _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
211 require.ErrorIs(t, err, ErrBindPolicy)
212 assert.Contains(t, err.Error(), "regular file or directory")
213 })
214
215 t.Run("socket refused", func(t *testing.T) {
216 socket := filepath.Join(root, "binds", "sock")
217 listener, err := net.Listen("unix", socket)
218 require.NoError(t, err)
219 defer func() { _ = listener.Close() }()
220 policy := validPolicy(root, socket)
221 _, err = policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
222 require.ErrorIs(t, err, ErrBindPolicy)
223 assert.Contains(t, err.Error(), "regular file or directory")
224 })
225
226 t.Run("regular file accepted", func(t *testing.T) {
227 file := filepath.Join(root, "binds", "app.conf")
228 require.NoError(t, os.WriteFile(file, []byte("x"), 0o644))
229 policy := validPolicy(root, file)
230 resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
231 require.NoError(t, err)
232 assert.Equal(t, file, resolved.Source)
233 })
234}
235
236func TestBindSourceTypeAllowed(t *testing.T) {
237 assert.True(t, bindSourceTypeAllowed(0))
238 assert.True(t, bindSourceTypeAllowed(os.ModeDir))
239 refused := []os.FileMode{
240 os.ModeSymlink,
241 os.ModeDevice,
242 os.ModeCharDevice,
243 os.ModeNamedPipe,
244 os.ModeSocket,
245 os.ModeIrregular,
246 }
247 for _, mode := range refused {
248 assert.False(t, bindSourceTypeAllowed(mode), mode.String())
249 }
250}
251
252func TestResolvedUnderRoot(t *testing.T) {
253 assert.True(t, resolvedUnderRoot("/srv/binds", "/srv/binds"))
254 assert.True(t, resolvedUnderRoot("/srv/binds", "/srv/binds/conf"))
255 assert.False(t, resolvedUnderRoot("/srv/binds", "/srv/binds-sibling"))
256 assert.False(t, resolvedUnderRoot("/srv/binds", "/srv/other"))
257 assert.False(t, resolvedUnderRoot("/srv/binds", "/etc"))
258}