acme: answer HTTP-01 from the server itself, on port 80
HTTP-01 always arrives on port 80 - the CA chooses the port, not the operator - so it never collides with an admin UI on 443. The conflict only exists for TLS-ALPN-01, which is the challenge type that does use 443. Given that, the server keeps a permanent listener on 80 that answers challenges from a webroot and redirects everything else to the admin UI. Same arrangement as the webroot plugins for Apache and nginx, and better than letting the ACME client bind 80 per renewal: nothing binds and unbinds, so a renewal cannot fail because the port was briefly busy, and the client needs only write access to a directory instead of the privilege to bind a low port. Port 80 also gets a use it would want anyway. The ACME client stays an external program. lego is also a Go library, but importing it would put a large dependency tree into a server that deliberately has none, and the CLI does the same job from a timer. Tokens are validated by *shape* before any filesystem call, so traversal never reaches the disk - a stronger guarantee than sanitising a path and trusting the sanitiser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6afcb131ef
commit
5d7f59a66a
@@ -0,0 +1,98 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package acmehttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func serve(t *testing.T, redirectTo string) (http.Handler, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := EnsureWebroot(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(root, redirectTo), root
|
||||
}
|
||||
|
||||
func TestServesAChallengeTokenWrittenByAnAcmeClient(t *testing.T) {
|
||||
h, root := serve(t, "https://admin.example.net")
|
||||
// Exactly what `lego --http.webroot` writes.
|
||||
token := "abc-123_XYZ"
|
||||
want := "abc-123_XYZ.keyauthorization-part"
|
||||
if err := os.WriteFile(filepath.Join(root, ".well-known", "acme-challenge", token), []byte(want), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+token, nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("challenge not served: %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != want {
|
||||
t.Fatalf("body = %q, want %q", rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// The token comes from the network and is used to build a path. Rejecting by *shape* means
|
||||
// traversal never reaches the filesystem at all, which is a stronger guarantee than sanitising.
|
||||
func TestTraversalNeverTouchesTheFilesystem(t *testing.T) {
|
||||
h, root := serve(t, "https://admin.example.net")
|
||||
secret := filepath.Join(filepath.Dir(root), "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("do not serve me"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"../secret.txt",
|
||||
"..%2Fsecret.txt",
|
||||
"../../etc/passwd",
|
||||
"..",
|
||||
".",
|
||||
"a/b",
|
||||
"tok%20en", // a space arrives percent-encoded; a literal one is not a valid request line
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+bad, nil))
|
||||
if rec.Code == 200 && rec.Body.String() == "do not serve me" {
|
||||
t.Fatalf("served a file outside the challenge directory via %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEverythingElseRedirectsToHTTPS(t *testing.T) {
|
||||
h, _ := serve(t, "https://admin.example.net")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/devices?page=2", nil))
|
||||
if rec.Code != http.StatusPermanentRedirect {
|
||||
t.Fatalf("code = %d, want 308", rec.Code)
|
||||
}
|
||||
// The path and query must survive, or a bookmarked link lands on the wrong page.
|
||||
if got := rec.Header().Get("Location"); got != "https://admin.example.net/devices?page=2" {
|
||||
t.Fatalf("Location = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no admin URL configured there is nowhere to send people, and inventing one would be worse
|
||||
// than saying so.
|
||||
func TestNoRedirectTargetIsHonest(t *testing.T) {
|
||||
h, _ := serve(t, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingTokenIsNotFound(t *testing.T) {
|
||||
h, _ := serve(t, "https://admin.example.net")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+"never-written", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user