aboutsummaryrefslogtreecommitdiff
path: root/internal/web
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 15:49:22 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 15:49:22 +0200
commit73d00113f5ed5fe99e365d2053dc70a6da8a81ee (patch)
treecb43b29be24768bf4a8f4baa5590664e279e7fb9 /internal/web
parent905b7473dd3987928e9ac9c81405548ba7be4c8d (diff)
downloadlectio-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')
-rw-r--r--internal/web/server.go25
-rw-r--r--internal/web/server_test.go53
2 files changed, 70 insertions, 8 deletions
diff --git a/internal/web/server.go b/internal/web/server.go
index 5886f0f..3fe1e4e 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -14,6 +14,7 @@ import (
"net"
"net/http"
"os/exec"
+ "regexp"
"runtime"
"strings"
"time"
@@ -57,6 +58,13 @@ func today() string {
return time.Now().Format("2006-01-02")
}
+// dateRe validates a ?date= query param before it is ever handed to
+// readings.Load/liturgy.Load, which build a filesystem cache path by string
+// concatenation from it -- an unvalidated date is a path-traversal vector.
+// Compiled once at package scope (not per request), the same shape as
+// internal/cli's dateRe. See resolveQuery.
+var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
+
// shiftDate adds days to date (YYYY-MM-DD); an unparsable date is returned
// unchanged, mirroring internal/tui's shiftDate.
func shiftDate(date string, days int) string {
@@ -119,7 +127,7 @@ func requestDisplay(cfg config.Config, r *http.Request) string {
// can't drift.
func resolveQuery(cfg config.Config, r *http.Request) (date, lectionary string, all bool, versions []string, display string) {
date = r.URL.Query().Get("date")
- if date == "" {
+ if date == "" || !dateRe.MatchString(date) {
date = today()
}
lectionary = requestLectionary(cfg, r)
@@ -328,17 +336,20 @@ func themeCSSHandler(cfg config.Config) http.HandlerFunc {
// defaultWebPort is the port chooseListener prefers when cfg.WebPort is 0.
const defaultWebPort = 1099
-// chooseListener binds the port to serve on. port==0 means "prefer
-// defaultWebPort (1099), else let the OS pick a free port"; a non-zero port
-// is bound exactly (and its bind error surfaced if the port is in use).
+// chooseListener binds the port to serve on, on loopback only (127.0.0.1) --
+// lectio-web is documented as a personal tool and Run prints an
+// http://localhost/... URL, so it must not be reachable from the LAN. port==0
+// means "prefer defaultWebPort (1099), else let the OS pick a free port"; a
+// non-zero port is bound exactly (and its bind error surfaced if the port is
+// in use).
func chooseListener(port int) (net.Listener, error) {
if port != 0 {
- return net.Listen("tcp", fmt.Sprintf(":%d", port))
+ return net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
}
- if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", defaultWebPort)); err == nil {
+ if ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", defaultWebPort)); err == nil {
return ln, nil
}
- return net.Listen("tcp", ":0") // 1099 taken -> any free port
+ return net.Listen("tcp", "127.0.0.1:0") // 1099 taken -> any free port
}
// Run starts lectio-web: listens on cfg.WebPort via chooseListener (0
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