From 73d00113f5ed5fe99e365d2053dc70a6da8a81ee Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 23 Jul 2026 15:49:22 +0200 Subject: web,liturgy: validate date against path traversal; bind lectio-web to localhost An unvalidated ?date= query param flowed straight into liturgy.Load's filepath.Join(dir, date+".json"/".html") before any network call, letting a crafted date (e.g. "../../../../etc/hostname") read an arbitrary file whose JSON, if present, unmarshals into []liturgy.Section and renders back to the client. Fix both layers: resolveQuery now falls back to today() on empty or non-YYYY-MM-DD date (mirroring requestDisplay's normalize-don't-trust pattern), and liturgy.Load itself rejects a non-matching date before building any cache path, protecting every caller even if a future one forgets to validate. Also bind lectio-web's listener to 127.0.0.1 instead of all interfaces: it is a personal tool whose Run already prints http://localhost:, so it should not be reachable from the LAN. --- internal/web/server_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) (limited to 'internal/web/server_test.go') diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 0268d3b..30c8fbf 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" @@ -25,7 +26,8 @@ func TestServer(t *testing.T) { })) defer fixtureServer.Close() liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia") - t.Setenv("XDG_CACHE_HOME", t.TempDir()) + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) srv := NewServer(config.Default()) @@ -196,6 +198,55 @@ func TestServer(t *testing.T) { t.Errorf("body missing display select: %q", rec.Body.String()) } }) + + // Regression coverage for the ?date= path-traversal finding: resolveQuery + // must reject anything that isn't YYYY-MM-DD and fall back to today(), + // the same "normalize, don't trust" pattern requestDisplay already uses. + t.Run("date path traversal does not read a planted cache file", func(t *testing.T) { + // cacheDir() == filepath.Join(cacheHome, "lectio"), so + // filepath.Join(cacheDir(), "../evil"+".json") resolves to + // cacheHome/evil.json -- one level *above* the real cache dir, and + // only reachable via an unvalidated "../" date. If the marker below + // ever appears in a response, liturgy.Load read this planted file. + evilPath := filepath.Join(cacheHome, "evil.json") + evilJSON := `[{"Heading":"LEAKED-VIA-TRAVERSAL","PartID":"ewangelia","Paragraphs":[["s"]]}]` + if err := os.WriteFile(evilPath, []byte(evilJSON), 0o644); err != nil { + t.Fatal(err) + } + + baseline := httptest.NewRecorder() + srv.ServeHTTP(baseline, httptest.NewRequest("GET", "/readings?v=wuj", nil)) // no date -> today() + if baseline.Code != http.StatusOK { + t.Fatalf("baseline status = %d, want 200", baseline.Code) + } + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=../evil&v=wuj", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "LEAKED-VIA-TRAVERSAL") { + t.Fatalf("traversal date reached the planted cache file outside the cache dir: %q", body) + } + if body != baseline.Body.String() { + t.Errorf("traversal date did not fall back to today() identically to omitting date\n got: %q\nwant: %q", body, baseline.Body.String()) + } + }) + + t.Run("date query with many ../ segments falls back to today, same as omitting date", func(t *testing.T) { + baseline := httptest.NewRecorder() + srv.ServeHTTP(baseline, httptest.NewRequest("GET", "/readings?v=wuj", nil)) // no date -> today() + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=../../../../etc/hostname&v=wuj", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Body.String() != baseline.Body.String() { + t.Errorf("traversal-shaped date did not behave identically to omitting date\n got: %q\nwant: %q", rec.Body.String(), baseline.Body.String()) + } + }) } // TestChooseListener exercises chooseListener's port-selection logic -- cgit v1.3