package server import ( "bytes" "crypto/tls" "encoding/json" "net/http" "net/http/httptest" "net/netip" "strings" "testing" "jarl/internal/api" ) // hreq is one request to the httpreq endpoint, as it would arrive from // remote (with TLS when tlsOn is set), with extra headers. type hreq struct { path string // after /acme/httpreq remote string tlsOn bool header map[string]string user string token string body any } func (e *apiEnv) httpreq(q hreq) *httptest.ResponseRecorder { e.t.Helper() var rd *bytes.Reader if q.body != nil { b, _ := json.Marshal(q.body) rd = bytes.NewReader(b) } else { rd = bytes.NewReader(nil) } method := "POST" if q.path == "" { method = "GET" } r := httptest.NewRequest(method, api.Prefix+"/acme/httpreq"+q.path, rd) r.RemoteAddr = q.remote if r.RemoteAddr == "" { r.RemoteAddr = "192.0.2.9:40000" } if q.tlsOn { r.TLS = &tls.ConnectionState{} } r.Header.Set("Content-Type", "application/json") for k, v := range q.header { r.Header.Set(k, v) } if q.token != "" { r.SetBasicAuth(q.user, q.token) } w := httptest.NewRecorder() e.srv.Handler().ServeHTTP(w, r) return w } func errCode(w *httptest.ResponseRecorder) string { var b api.ErrorBody json.Unmarshal(w.Body.Bytes(), &b) return b.Error.Code } func challenge(value string) map[string]any { return map[string]any{"fqdn": "_acme-challenge.example.com.", "value": value} } func (e *apiEnv) txtValues(zone, name string) []string { e.t.Helper() var out []string for _, r := range e.list("/zones/"+zone+"/records", e.token) { if r["type"] == "TXT" && r["name"] == name { out = append(out, r["data"].(string)) } } return out } func TestHTTPReqDisabledByDefault(t *testing.T) { e := newAPIEnv(t) w := e.httpreq(hreq{path: "/present", tlsOn: true, user: "admin", token: e.token, body: challenge("abc")}) if w.Code != 404 || errCode(w) != "disabled" { t.Errorf("disabled endpoint = %d %s", w.Code, w.Body) } w = e.httpreq(hreq{}) var st map[string]any json.Unmarshal(w.Body.Bytes(), &st) if w.Code != 200 || st["enabled"] != false { t.Errorf("status = %d %s", w.Code, w.Body) } } func TestHTTPReqTransport(t *testing.T) { proxy := netip.MustParsePrefix("10.0.0.2/32") e := newAPIEnvConfig(t, Config{Version: "test", TrustedProxies: []netip.Prefix{proxy}, HTTPReq: HTTPReq{Enabled: true}}) e.exampleZone() cases := []struct { name string remote string tlsOn bool header map[string]string ok bool }{ {"plain HTTP", "192.0.2.9:1", false, nil, false}, {"jarld's own TLS", "192.0.2.9:1", true, nil, true}, {"header from an untrusted client", "192.0.2.9:1", false, map[string]string{"X-Forwarded-Proto": "https"}, false}, {"trusted proxy, https", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "https"}, true}, {"trusted proxy, http", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "http"}, false}, {"trusted proxy, no header", "10.0.0.2:1", false, nil, false}, {"client-sent https, proxy appended http", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "https, http"}, false}, {"client-sent http, proxy appended https", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "http,https"}, true}, {"trusted proxy, Forwarded", "10.0.0.2:1", false, map[string]string{"Forwarded": `for=192.0.2.9;proto=https`}, true}, {"trusted proxy, Forwarded http", "10.0.0.2:1", false, map[string]string{"Forwarded": `for=192.0.2.9;Proto="http"`}, false}, {"loopback, not allowed", "127.0.0.1:1", false, nil, false}, } for _, c := range cases { w := e.httpreq(hreq{path: "/present", remote: c.remote, tlsOn: c.tlsOn, header: c.header, user: "admin", token: e.token, body: challenge("v1")}) if c.ok && w.Code != 200 || !c.ok && (w.Code != 403 || errCode(w) != "https_required") { t.Errorf("%s: %d %s", c.name, w.Code, w.Body) } if !c.ok && !strings.Contains(w.Body.String(), "unencrypted") { t.Errorf("%s: no warning about the exposed token: %s", c.name, w.Body) } // The status check agrees. s := e.httpreq(hreq{remote: c.remote, tlsOn: c.tlsOn, header: c.header}) var st map[string]any json.Unmarshal(s.Body.Bytes(), &st) if st["accepted"] != c.ok || st["transport"] == "" { t.Errorf("%s: status = %s", c.name, s.Body) } } // Cookies follow the same rules (cookie_secure: auto). r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = "10.0.0.2:1" r.Header.Set("Forwarded", "proto=https") if !e.srv.isHTTPS(r) { t.Error("Forwarded: proto=https from a trusted proxy isn't HTTPS") } } func TestHTTPReqLoopbackAndPlain(t *testing.T) { e := newAPIEnvConfig(t, Config{Version: "test", TrustedProxies: []netip.Prefix{netip.MustParsePrefix("::1/128")}, HTTPReq: HTTPReq{Enabled: true, AllowLoopbackHTTP: true}}) e.exampleZone() for remote, ok := range map[string]bool{ "127.0.0.1:1": true, // lego on this host "192.0.2.9:1": false, // still HTTPS-only from the network "[::1]:1": false, // a trusted proxy: its headers decide, and it sent none "10.20.30.40:1": false, } { w := e.httpreq(hreq{path: "/present", remote: remote, user: "admin", token: e.token, body: challenge("v1")}) if ok && w.Code != 200 || !ok && w.Code != 403 { t.Errorf("%s: %d %s", remote, w.Code, w.Body) } } e = newAPIEnvConfig(t, Config{Version: "test", HTTPReq: HTTPReq{Enabled: true, AllowPlainHTTP: true}}) e.exampleZone() if w := e.httpreq(hreq{path: "/present", user: "admin", token: e.token, body: challenge("v1")}); w.Code != 200 { t.Errorf("require_https off: %d %s", w.Code, w.Body) } } func TestHTTPReqChallenges(t *testing.T) { e := newAPIEnvConfig(t, Config{Version: "test", HTTPReq: HTTPReq{Enabled: true}}) e.exampleZone() e.do("POST", "/users", e.token, map[string]any{"username": "lego", "password": "lego-password-123", "grant_strings": []string{"records:write names=_acme-challenge.* types=TXT"}}, 201) var legoID int64 for _, u := range e.list("/users", e.token) { if u["username"] == "lego" { legoID = int64(u["id"].(float64)) } } tok := e.do("POST", "/users/"+itoa(legoID)+"/tokens", e.token, map[string]any{"name": "lego"}, 201)["token"].(string) send := func(path, user, token string, body any) *httptest.ResponseRecorder { return e.httpreq(hreq{path: path, tlsOn: true, user: user, token: token, body: body}) } const name = "_acme-challenge.example.com." // present, a retry of it, and a second value (apex and wildcard). for _, v := range []string{"v1", "v1", "v2"} { if w := send("/present", "lego", tok, challenge(v)); w.Code != 200 { t.Fatalf("present %s: %d %s", v, w.Code, w.Body) } } if got := e.txtValues("example.com", name); len(got) != 2 { t.Errorf("after present: %v", got) } // cleanup removes one value; repeating it still succeeds. for i := 0; i < 2; i++ { if w := send("/cleanup", "lego", tok, challenge("v1")); w.Code != 200 { t.Fatalf("cleanup: %d %s", w.Code, w.Body) } } if got := e.txtValues("example.com", name); len(got) != 1 || !strings.Contains(got[0], "v2") { t.Errorf("after cleanup: %v", got) } // Credentials. for _, c := range []struct { name, user, token string }{ {"no credentials", "", ""}, {"wrong user", "admin", tok}, {"bad token", "lego", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, } { w := send("/present", c.user, c.token, challenge("v3")) if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" { t.Errorf("%s: %d %s", c.name, w.Code, w.Body) } } // A Bearer token works too. w := e.httpreq(hreq{path: "/cleanup", tlsOn: true, header: map[string]string{"Authorization": "Bearer " + tok}, body: challenge("v2")}) if w.Code != 200 { t.Errorf("bearer: %d %s", w.Code, w.Body) } // Bodies and names. for _, c := range []struct { name string body any code int }{ {"RAW mode", map[string]any{"domain": "example.com", "token": "t", "keyAuth": "k"}, 400}, {"no value", map[string]any{"fqdn": name}, 400}, {"value with a quote", map[string]any{"fqdn": name, "value": `a"b`}, 400}, {"wildcard", map[string]any{"fqdn": "*.example.com.", "value": "v"}, 400}, {"outside the token's grants", map[string]any{"fqdn": "www.example.com.", "value": "v"}, 403}, {"no such zone", map[string]any{"fqdn": "_acme-challenge.example.net.", "value": "v"}, 404}, } { if w := send("/present", "lego", tok, c.body); w.Code != c.code { t.Errorf("%s: %d, want %d: %s", c.name, w.Code, c.code, w.Body) } } if w := send("/view/nosuch/present", "lego", tok, challenge("v4")); w.Code != 404 { t.Errorf("unknown view: %d %s", w.Code, w.Body) } if w := e.httpreq(hreq{path: "/present", tlsOn: true, user: "lego", token: tok, body: challenge("v5")}); w.Code != http.StatusOK { t.Errorf("final present: %d", w.Code) } } func (e *apiEnv) exampleZone() { e.t.Helper() e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) }