aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/liturgy/fetch.go12
-rw-r--r--internal/liturgy/fetch_test.go28
-rw-r--r--internal/web/server.go25
-rw-r--r--internal/web/server_test.go53
4 files changed, 110 insertions, 8 deletions
diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go
index 5447552..32eaaad 100644
--- a/internal/liturgy/fetch.go
+++ b/internal/liturgy/fetch.go
@@ -33,6 +33,14 @@ const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " +
// ewangelia.py's fetch() for the original behaviour this mirrors.
var publishedRe = regexp.MustCompile(`id="\w*0all"`)
+// dateRe is the same YYYY-MM-DD shape internal/cli's dateRe validates
+// against. Load checks opts.Date against it before building any filesystem
+// path (jsonPath/htmlPath below are built by string concatenation, so an
+// unvalidated Date is a path-traversal vector) -- defense-in-depth so every
+// caller (web, cli, tui) is protected even if a future caller forgets to
+// validate its own input first.
+var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
+
// Options controls how Load resolves a day's readings.
type Options struct {
// Date is the day to load, formatted YYYY-MM-DD.
@@ -71,6 +79,10 @@ func cacheDir() string {
// has been harvested, and only surfaces the original fetch error if
// that fallback also fails.
func Load(opts Options) ([]Section, error) {
+ if !dateRe.MatchString(opts.Date) {
+ return nil, fmt.Errorf("invalid date %q: want YYYY-MM-DD", opts.Date)
+ }
+
if opts.Offline {
return LoadOffline(opts.Date)
}
diff --git a/internal/liturgy/fetch_test.go b/internal/liturgy/fetch_test.go
index ae8a68c..1cebb86 100644
--- a/internal/liturgy/fetch_test.go
+++ b/internal/liturgy/fetch_test.go
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "path/filepath"
"testing"
)
@@ -30,3 +31,30 @@ func TestLoadCaches(t *testing.T) {
t.Error("cache returned different section count")
}
}
+
+// TestLoadRejectsInvalidDate is the liturgy-layer defense-in-depth check for
+// the ?date= path-traversal finding: Load must reject a non-YYYY-MM-DD date
+// before it ever builds a filesystem path from it, so every caller (web,
+// cli, tui) is protected even if a future caller forgets to validate.
+//
+// The planted "passwd.json" sits one level *above* cacheDir() -- reachable
+// only via a "../" date -- so if Load ever built jsonPath from the raw date
+// unchecked, loadJSONCache would read it back and return its section instead
+// of an error.
+func TestLoadRejectsInvalidDate(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CACHE_HOME", dir)
+
+ evilPath := filepath.Join(dir, "passwd.json")
+ if err := os.WriteFile(evilPath, []byte(`[{"Heading":"SHOULD-NEVER-BE-READ"}]`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ secs, err := Load(Options{Date: "../passwd"})
+ if err == nil {
+ t.Fatalf("Load(Date=%q) = (%v, nil), want a non-nil error", "../passwd", secs)
+ }
+ if secs != nil {
+ t.Errorf("Load(Date=%q) sections = %v, want nil", "../passwd", secs)
+ }
+}
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