1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
package main

import (
	"io"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// askDecision is the only thing standing between the open internet and Caddy
// minting certificates, so the interesting cases here are the ones where a
// plausible-looking domain must still be refused.
func TestAskDecision(t *testing.T) {
	const base = "hosty-test.profpatsch.de"

	// Pretend hosty-hello is attached and nothing else is.
	attached := func(name string) bool { return name == "hosty-hello" }

	for _, tc := range []struct {
		name   string
		domain string
		want   bool
		// reason is a substring the explanation must contain, so that a
		// refusal is checked for the right cause rather than merely refusing.
		reason string
	}{
		{
			name:   "attached app is allowed",
			domain: "hosty-hello." + base,
			want:   true,
			reason: "attached",
		},
		{
			name:   "trailing dot is tolerated (SNI may be fully qualified)",
			domain: "hosty-hello." + base + ".",
			want:   true,
			reason: "attached",
		},
		{
			name:   "installed but detached app is refused",
			domain: "rss-parrot." + base,
			want:   false,
			reason: "not attached",
		},

		// The whole point of the endpoint: names outside the base domain must
		// never be authorised, no matter what they look like.
		{
			name:   "unrelated domain",
			domain: "example.com",
			want:   false,
			reason: "not under",
		},
		{
			name:   "a production vhost on this very host",
			domain: "profpatsch.de",
			want:   false,
			reason: "not under",
		},
		{
			name:   "base domain as a prefix, not a suffix",
			domain: base + ".evil.com",
			want:   false,
			reason: "not under",
		},
		{
			name:   "suffix match without the dot separator",
			domain: "evil" + base,
			want:   false,
			reason: "not under",
		},
		{
			name:   "the base domain itself is not an app",
			domain: base,
			want:   false,
			reason: "not under",
		},

		// The wildcard A record resolves at any depth, so deeper names really
		// do arrive here and must not be collapsed into an app name.
		{
			name:   "two labels deep",
			domain: "a.b." + base,
			want:   false,
			reason: "more than one label",
		},
		{
			name:   "deep name whose last label is an attached app",
			domain: "evil.hosty-hello." + base,
			want:   false,
			reason: "more than one label",
		},

		// A name that could never have been installed must be refused before
		// the filesystem is touched at all.
		{
			name:   "uppercase label",
			domain: "MyApp." + base,
			want:   false,
			reason: "not lowercase",
		},
		{
			name:   "label with underscore",
			domain: "bad_name." + base,
			want:   false,
			reason: "invalid app name",
		},
		{
			name:   "reserved label",
			domain: "www." + base,
			want:   false,
			reason: "invalid app name",
		},
		{
			name:   "companion-unit suffix",
			domain: "hosty-hello-fs." + base,
			want:   false,
			reason: "invalid app name",
		},
		{
			name:   "empty label",
			domain: "." + base,
			want:   false,
			reason: "no app label",
		},
		{
			name:   "empty domain",
			domain: "",
			want:   false,
			reason: "empty domain",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			ok, reason := askDecision(tc.domain, base, attached)
			if ok != tc.want {
				t.Fatalf("askDecision(%q) = %v (%s), want %v",
					tc.domain, ok, reason, tc.want)
			}
			if !strings.Contains(reason, tc.reason) {
				t.Fatalf("askDecision(%q) reason = %q, want it to mention %q",
					tc.domain, reason, tc.reason)
			}
		})
	}
}

// The attachment check must be consulted only after the domain has been
// validated: a malformed name should never reach a filesystem lookup, and an
// attached app must not be authorised under someone else's domain.
func TestAskDecisionDoesNotConsultAttachmentForBadNames(t *testing.T) {
	const base = "hosty-test.profpatsch.de"

	for _, domain := range []string{
		"MyApp." + base,
		"bad_name." + base,
		"a.b." + base,
		"example.com",
		"hosty-hello.evil.com",
	} {
		called := false
		attached := func(string) bool { called = true; return true }
		ok, _ := askDecision(domain, base, attached)
		if ok {
			t.Fatalf("askDecision(%q) authorised a domain it should have refused", domain)
		}
		if called {
			t.Fatalf("askDecision(%q) consulted attachment state for an invalid domain", domain)
		}
	}
}

// The HTTP surface, exercised without binding a socket. What matters to Caddy
// is the status code alone: any 2xx authorises issuance, anything else refuses.
func TestAskMuxStatusCodes(t *testing.T) {
	const base = "hosty-test.profpatsch.de"
	attached := func(name string) bool { return name == "hosty-hello" }
	mux := askMux(base, attached, io.Discard)

	for _, tc := range []struct {
		name string
		path string
		want int
	}{
		{"attached app", "/ask?domain=hosty-hello." + base, 200},
		{"detached app", "/ask?domain=rss-parrot." + base, 404},
		{"foreign domain", "/ask?domain=profpatsch.de", 404},
		{"missing domain parameter", "/ask", 404},
		{"empty domain parameter", "/ask?domain=", 404},
		{"unknown path is not consent", "/", 404},
		{"probing another path", "/anything", 404},
		// The query value arrives percent-encoded from Caddy's side; make sure
		// decoding does not open a hole.
		{"encoded dots do not defeat label counting", "/ask?domain=a%2Eb." + base, 404},
	} {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest("GET", tc.path, nil)
			rec := httptest.NewRecorder()
			mux.ServeHTTP(rec, req)
			if rec.Code != tc.want {
				t.Fatalf("GET %s = %d, want %d (body %q)",
					tc.path, rec.Code, tc.want, rec.Body.String())
			}
		})
	}
}

// isPublished must detect an attached app whose unit is a *symlink* pointing
// somewhere the caller cannot read.
//
// portabled attaches with --copy=symlink, so the unit under
// /run/systemd/system.attached is a link into /var/lib/hosty/images/<name>/...,
// and that directory is 0700 root. os.Stat follows the link and fails with
// EACCES for the unprivileged ask service, making every app look detached —
// which is exactly what happened on the first deploy. os.Lstat looks at the
// link itself and is what the check actually wants.
//
// The stubbed attachedFn in the tests above cannot catch this, because it never
// touches a filesystem; only the shape of the real path does.
func TestIsPublishedUsesLstat(t *testing.T) {
	dir := t.TempDir()

	// A symlink whose target does not exist stands in for a target that cannot
	// be read: both make Stat fail while Lstat succeeds.
	link := filepath.Join(dir, "attached.service")
	if err := os.Symlink(filepath.Join(dir, "unreadable", "unit.service"), link); err != nil {
		t.Fatalf("creating symlink: %v", err)
	}

	if _, err := os.Stat(link); err == nil {
		t.Fatal("precondition failed: Stat should not resolve this link")
	}
	if _, err := os.Lstat(link); err != nil {
		t.Fatalf("precondition failed: Lstat should see the link: %v", err)
	}

	// fileExists is Stat-based and would report the app as detached; that is
	// the bug this test pins down.
	if fileExists(link) {
		t.Fatal("fileExists unexpectedly followed a broken link")
	}

	// The real check must report it as present.
	if !isPublishedIn(dir, "attached") {
		t.Fatal("isPublished did not see an attached app whose unit is a symlink")
	}

	// ...and must still say no for an app that was never attached.
	if isPublishedIn(dir, "never-attached") {
		t.Fatal("isPublished reported an app that has no unit at all")
	}
}