From d6f7f18b041de650b9a35ffc5d791fdcc7cb5049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 11 Aug 2026 21:23:18 +0200 Subject: [PATCH] Merge commit from fork * fileserver: add failing test for calculateEtag collision calculateEtag concatenates base36(mtime) and base36(size) with no separator, so distinct (mtime, size) pairs can yield identical digit strings and thus identical ETags. * fileserver: prevent ETag collisions by separating mtime and size components calculateEtag concatenated base36(mtime) and base36(size) with no separator, so distinct (mtime, size) pairs could decode to the same digit string and produce identical ETags. --- modules/caddyhttp/fileserver/staticfiles.go | 1 + .../caddyhttp/fileserver/staticfiles_test.go | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go index 3ca7452e1..bd9df8223 100644 --- a/modules/caddyhttp/fileserver/staticfiles.go +++ b/modules/caddyhttp/fileserver/staticfiles.go @@ -768,6 +768,7 @@ func calculateEtag(d os.FileInfo) string { var sb strings.Builder sb.WriteRune('"') sb.WriteString(strconv.FormatInt(mtime.UnixNano(), 36)) + sb.WriteRune('-') sb.WriteString(strconv.FormatInt(d.Size(), 36)) sb.WriteRune('"') return sb.String() diff --git a/modules/caddyhttp/fileserver/staticfiles_test.go b/modules/caddyhttp/fileserver/staticfiles_test.go index a5f314ffc..a077a2011 100644 --- a/modules/caddyhttp/fileserver/staticfiles_test.go +++ b/modules/caddyhttp/fileserver/staticfiles_test.go @@ -189,6 +189,34 @@ func check_validator_headers(modTime time.Time, expect_headers bool, t *testing. } } +// calculateEtag concatenates the base-36 mtime and size with no separator, +// so distinct (mtime, size) pairs can produce the same digit string and +// therefore the same ETag. +func TestCalculateEtagCollision(t *testing.T) { + fileA := fakeFileInfo{size: 75, modTime: time.Unix(2, 0)} + fileB := fakeFileInfo{size: 3, modTime: time.Unix(72, 2)} + + etagA := calculateEtag(fileA) + etagB := calculateEtag(fileB) + + if etagA == etagB { + t.Fatalf("etag collision: distinct files (size=%d mtime=%s) and (size=%d mtime=%s) both produced ETag %s", + fileA.size, fileA.modTime, fileB.size, fileB.modTime, etagA) + } +} + +type fakeFileInfo struct { + size int64 + modTime time.Time +} + +func (f fakeFileInfo) Name() string { return "fake" } +func (f fakeFileInfo) Size() int64 { return f.size } +func (f fakeFileInfo) Mode() os.FileMode { return 0 } +func (f fakeFileInfo) ModTime() time.Time { return f.modTime } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } + func TestPrecompressedRangeResponse(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "range.txt"), []byte("original response body"), 0o600); err != nil {