diff options
Diffstat (limited to 'internal/web')
| -rw-r--r-- | internal/web/server.go | 25 | ||||
| -rw-r--r-- | internal/web/server_test.go | 53 |
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 |
