diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 15:49:22 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 15:49:22 +0200 |
| commit | 73d00113f5ed5fe99e365d2053dc70a6da8a81ee (patch) | |
| tree | cb43b29be24768bf4a8f4baa5590664e279e7fb9 /internal/web/server_test.go | |
| parent | 905b7473dd3987928e9ac9c81405548ba7be4c8d (diff) | |
| download | lectio-73d00113f5ed5fe99e365d2053dc70a6da8a81ee.tar.gz lectio-73d00113f5ed5fe99e365d2053dc70a6da8a81ee.zip | |
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:<port>, so it should not be reachable from the LAN.
Diffstat (limited to 'internal/web/server_test.go')
| -rw-r--r-- | internal/web/server_test.go | 53 |
1 files changed, 52 insertions, 1 deletions
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 |
