diff options
Diffstat (limited to 'internal/liturgy')
| -rw-r--r-- | internal/liturgy/fetch.go | 12 | ||||
| -rw-r--r-- | internal/liturgy/fetch_test.go | 28 |
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) + } +} |
