summaryrefslogtreecommitdiff
path: root/internal/liturgy
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/liturgy
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/liturgy')
-rw-r--r--internal/liturgy/fetch.go12
-rw-r--r--internal/liturgy/fetch_test.go28
2 files changed, 40 insertions, 0 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)
+ }
+}