diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a2c1f..da44113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.1] - 2026-04-04 + +### Changed + +- **request** — `MatchesRegexp` now caches compiled `*regexp.Regexp` patterns in a `sync.Map`, eliminating repeated recompilation +- **request** — `Validation.MatchesPattern` delegates to the cached `MatchesRegexp` instead of calling `regexp.MatchString` directly +- **response** — `write()` marshals JSON to a buffer first and sets the `Content-Length` header before writing, improving client framing behavior +- **response** — JSONP output is now prefixed with `/**/` to mitigate Rosetta Flash and content-type-sniffing attacks + +### Fixed + +- **response** — JSONP `callback` parameter is validated against `^[a-zA-Z_$][a-zA-Z0-9_$.]*$`; invalid names return 400 to prevent XSS injection +- **response** — JSON encoding errors in `write()` are now caught before headers are sent, returning a clean 500 instead of a truncated body + ## [0.14.0] - 2026-04-03 ### Added diff --git a/request/query.go b/request/query.go index fbdd4aa..e3cc7a3 100644 --- a/request/query.go +++ b/request/query.go @@ -171,6 +171,10 @@ func (q *Query) Has(key string) bool { } // --- Standalone helpers (for when you don't want to create a Query object) --- +// +// Note: each standalone helper calls QueryFrom(r), which re-parses r.URL.RawQuery. +// When reading multiple query parameters from the same request, prefer creating a +// single Query via QueryFrom(r) and reusing it. // QueryString returns a query parameter as a string. func QueryString(r *http.Request, key, defaultVal string) string { diff --git a/request/tag_validator.go b/request/tag_validator.go index c608df8..778906f 100644 --- a/request/tag_validator.go +++ b/request/tag_validator.go @@ -9,11 +9,15 @@ import ( "slices" "strconv" "strings" + "sync" "unicode" "github.com/KARTIKrocks/apikit/errors" ) +// regexpCache caches compiled regular expressions to avoid recompilation. +var regexpCache sync.Map // string → *regexp.Regexp + // uuidRegex matches standard UUID format (8-4-4-4-12 hex digits). var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) @@ -406,7 +410,15 @@ func IsValidUUID(s string) bool { } // MatchesRegexp checks whether s matches the given regexp pattern. +// Compiled patterns are cached to avoid recompilation on repeated calls. func MatchesRegexp(s, pattern string) bool { - matched, err := regexp.MatchString(pattern, s) - return err == nil && matched + re, ok := regexpCache.Load(pattern) + if !ok { + compiled, err := regexp.Compile(pattern) + if err != nil { + return false + } + re, _ = regexpCache.LoadOrStore(pattern, compiled) + } + return re.(*regexp.Regexp).MatchString(s) } diff --git a/request/validate.go b/request/validate.go index f01e29d..12e1a0f 100644 --- a/request/validate.go +++ b/request/validate.go @@ -1,7 +1,6 @@ package request import ( - "regexp" "slices" "github.com/KARTIKrocks/apikit/errors" @@ -145,12 +144,12 @@ func (v *Validation) UUID(field, value string) { } // MatchesPattern validates that a string matches the given regex pattern. +// Compiled patterns are cached to avoid recompilation on repeated calls. func (v *Validation) MatchesPattern(field, value, pattern, message string) { if value == "" { return } - matched, err := regexp.MatchString(pattern, value) - if err != nil || !matched { + if !MatchesRegexp(value, pattern) { v.AddError(field, message) } } diff --git a/response/response.go b/response/response.go index 6ebfc9b..e1a1643 100644 --- a/response/response.go +++ b/response/response.go @@ -30,6 +30,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "time" "github.com/KARTIKrocks/apikit/errors" @@ -68,17 +69,23 @@ type TypedEnvelope[T any] struct { // --- Core write function --- // write is the internal function that writes the response. -// All public functions ultimately call this. +// All public functions ultimately call this. It marshals to a buffer first +// so that the Content-Length header can be set, improving client behavior. func write(w http.ResponseWriter, statusCode int, response any) { + b, err := json.Marshal(response) + if err != nil { + slog.Error("failed to encode JSON response", "error", err) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Length", strconv.Itoa(len(b)+1)) // +1 for newline w.WriteHeader(statusCode) - - if err := json.NewEncoder(w).Encode(response); err != nil { - // Headers and status are already sent — we cannot write a new status code. - // Log the error for operators; the client will see a truncated/malformed response. - slog.Error("failed to encode JSON response", "error", err) - } + _, _ = w.Write(b) + _, _ = w.Write([]byte("\n")) } // --- Success responses --- diff --git a/response/stream.go b/response/stream.go index 6b8ec60..234e875 100644 --- a/response/stream.go +++ b/response/stream.go @@ -10,11 +10,15 @@ import ( "net/http" "net/url" "path/filepath" + "regexp" "strings" "github.com/KARTIKrocks/apikit/errors" ) +// validJSONPCallback matches safe JSONP callback names: identifiers with optional dots. +var validJSONPCallback = regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$.]*$`) + // Stream provides Server-Sent Events (SSE) streaming. // // response.Stream(w, func(send func(event, data string) error) error { @@ -176,6 +180,7 @@ func PureJSON(w http.ResponseWriter, statusCode int, data any) { // JSONP writes a JSONP response for cross-domain callbacks. // The callback name is read from the "callback" query parameter. // If no callback is provided, it falls back to a regular JSON response. +// The callback name is validated to prevent XSS injection. func JSONP(w http.ResponseWriter, r *http.Request, statusCode int, data any) { callback := r.URL.Query().Get("callback") if callback == "" { @@ -186,6 +191,11 @@ func JSONP(w http.ResponseWriter, r *http.Request, statusCode int, data any) { return } + if !validJSONPCallback.MatchString(callback) { + BadRequest(w, "Invalid JSONP callback name") + return + } + b, err := json.Marshal(data) if err != nil { slog.Error("failed to encode JSONP response", "error", err) @@ -196,7 +206,7 @@ func JSONP(w http.ResponseWriter, r *http.Request, statusCode int, data any) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(statusCode) - _, _ = fmt.Fprintf(w, "%s(%s);", callback, b) + _, _ = fmt.Fprintf(w, "/**/%s(%s);", callback, b) } // Reader streams data from an io.Reader to the response. diff --git a/response/stream_test.go b/response/stream_test.go index 6a8959d..c8b28ea 100644 --- a/response/stream_test.go +++ b/response/stream_test.go @@ -130,8 +130,8 @@ func TestJSONP_WithCallback(t *testing.T) { t.Errorf("content-type: expected application/javascript, got %q", ct) } body := w.Body.String() - if !strings.HasPrefix(body, "myFunc(") { - t.Errorf("expected body to start with myFunc(, got %q", body) + if !strings.HasPrefix(body, "/**/myFunc(") { + t.Errorf("expected body to start with /**/myFunc(, got %q", body) } if !strings.HasSuffix(body, ");") { t.Errorf("expected body to end with );, got %q", body)