Skip to content

Commit 98edaa6

Browse files
committed
fix: support weak and list if-none-match etag validation
1 parent 78b24e8 commit 98edaa6

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

backend/main.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"log"
1212
"mime"
1313
"net/http"
14+
"net/textproto"
1415
"os"
1516
"path/filepath"
1617
"strconv"
@@ -421,7 +422,7 @@ func registerFrontendRoutes(r *gin.Engine, frontendDistDir string, appVersion st
421422
c.Header("X-App-Version", appVersion)
422423
c.Header("X-Frontend-Fingerprint", indexFingerprint)
423424

424-
if c.GetHeader("If-None-Match") == indexETag {
425+
if ifNoneMatchMatchesCurrentETag(c.GetHeader("If-None-Match"), indexETag) {
425426
c.Status(http.StatusNotModified)
426427
c.Abort()
427428
return
@@ -511,6 +512,76 @@ func calcContentFingerprint(content []byte) string {
511512
return hex.EncodeToString(sum[:8])
512513
}
513514

515+
func ifNoneMatchMatchesCurrentETag(ifNoneMatch string, currentETag string) bool {
516+
if ifNoneMatch == "" || currentETag == "" {
517+
return false
518+
}
519+
520+
buf := ifNoneMatch
521+
for {
522+
buf = textproto.TrimString(buf)
523+
if len(buf) == 0 {
524+
break
525+
}
526+
if buf[0] == ',' {
527+
buf = buf[1:]
528+
continue
529+
}
530+
if buf[0] == '*' {
531+
rest := textproto.TrimString(buf[1:])
532+
if rest == "" || rest[0] == ',' {
533+
return true
534+
}
535+
}
536+
537+
etag, remain := scanETagToken(buf)
538+
if etag == "" {
539+
buf = skipToNextETagToken(buf)
540+
continue
541+
}
542+
if etagWeakMatch(etag, currentETag) {
543+
return true
544+
}
545+
buf = remain
546+
}
547+
548+
return false
549+
}
550+
551+
func scanETagToken(s string) (etag string, remain string) {
552+
s = textproto.TrimString(s)
553+
start := 0
554+
if strings.HasPrefix(s, "W/") {
555+
start = 2
556+
}
557+
if len(s[start:]) < 2 || s[start] != '"' {
558+
return "", ""
559+
}
560+
561+
for i := start + 1; i < len(s); i++ {
562+
c := s[i]
563+
switch {
564+
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
565+
case c == '"':
566+
return s[:i+1], s[i+1:]
567+
default:
568+
return "", ""
569+
}
570+
}
571+
return "", ""
572+
}
573+
574+
func etagWeakMatch(a string, b string) bool {
575+
return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
576+
}
577+
578+
func skipToNextETagToken(s string) string {
579+
if idx := strings.IndexByte(s, ','); idx >= 0 {
580+
return s[idx+1:]
581+
}
582+
return ""
583+
}
584+
514585
func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
515586
allowed := parseCommaListEnv("CORS_ALLOWED_ORIGINS")
516587
if len(allowed) == 0 {

backend/main_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ func TestRegisterFrontendRoutesServesIndexWithRevalidateHeaders(t *testing.T) {
7878
if conditionalRec.Code != http.StatusNotModified {
7979
t.Fatalf("expected 304 for matching etag, got %d", conditionalRec.Code)
8080
}
81+
82+
weakReq := httptest.NewRequest(http.MethodGet, "/", nil)
83+
weakReq.Header.Set("If-None-Match", "W/"+etag)
84+
weakRec := httptest.NewRecorder()
85+
r.ServeHTTP(weakRec, weakReq)
86+
if weakRec.Code != http.StatusNotModified {
87+
t.Fatalf("expected 304 for weak etag match, got %d", weakRec.Code)
88+
}
89+
90+
multiReq := httptest.NewRequest(http.MethodGet, "/", nil)
91+
multiReq.Header.Set("If-None-Match", `"other-tag", W/`+etag)
92+
multiRec := httptest.NewRecorder()
93+
r.ServeHTTP(multiRec, multiReq)
94+
if multiRec.Code != http.StatusNotModified {
95+
t.Fatalf("expected 304 for multi-etag match, got %d", multiRec.Code)
96+
}
97+
98+
starReq := httptest.NewRequest(http.MethodGet, "/", nil)
99+
starReq.Header.Set("If-None-Match", "*")
100+
starRec := httptest.NewRecorder()
101+
r.ServeHTTP(starRec, starReq)
102+
if starRec.Code != http.StatusNotModified {
103+
t.Fatalf("expected 304 for wildcard if-none-match, got %d", starRec.Code)
104+
}
81105
}
82106

83107
func TestRegisterFrontendRoutesServesAssetsWithImmutableCache(t *testing.T) {
@@ -289,3 +313,68 @@ func TestRegisterFrontendRoutesFallsBackToEmbeddedAssets(t *testing.T) {
289313
t.Fatalf("embedded index.html seems invalid")
290314
}
291315
}
316+
317+
func TestIfNoneMatchMatchesCurrentETag(t *testing.T) {
318+
currentETag := `"sbpm-1234"`
319+
cases := []struct {
320+
name string
321+
ifNoneMatch string
322+
want bool
323+
}{
324+
{
325+
name: "exact",
326+
ifNoneMatch: `"sbpm-1234"`,
327+
want: true,
328+
},
329+
{
330+
name: "weak",
331+
ifNoneMatch: `W/"sbpm-1234"`,
332+
want: true,
333+
},
334+
{
335+
name: "list",
336+
ifNoneMatch: `"other", W/"sbpm-1234"`,
337+
want: true,
338+
},
339+
{
340+
name: "wildcard",
341+
ifNoneMatch: `*`,
342+
want: true,
343+
},
344+
{
345+
name: "wildcard with spaces and list",
346+
ifNoneMatch: `* , "other"`,
347+
want: true,
348+
},
349+
{
350+
name: "invalid wildcard token",
351+
ifNoneMatch: `*foo`,
352+
want: false,
353+
},
354+
{
355+
name: "invalid",
356+
ifNoneMatch: `not-an-etag`,
357+
want: false,
358+
},
359+
{
360+
name: "invalid then valid",
361+
ifNoneMatch: `not-an-etag, W/"sbpm-1234"`,
362+
want: true,
363+
},
364+
{
365+
name: "no-match",
366+
ifNoneMatch: `"other"`,
367+
want: false,
368+
},
369+
}
370+
371+
for _, tc := range cases {
372+
tc := tc
373+
t.Run(tc.name, func(t *testing.T) {
374+
got := ifNoneMatchMatchesCurrentETag(tc.ifNoneMatch, currentETag)
375+
if got != tc.want {
376+
t.Fatalf("unexpected result for %q: got %v want %v", tc.ifNoneMatch, got, tc.want)
377+
}
378+
})
379+
}
380+
}

0 commit comments

Comments
 (0)