Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions request/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions request/tag_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}$`)

Expand Down Expand Up @@ -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)
}
5 changes: 2 additions & 3 deletions request/validate.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package request

import (
"regexp"
"slices"

"github.com/KARTIKrocks/apikit/errors"
Expand Down Expand Up @@ -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)
}
}
21 changes: 14 additions & 7 deletions response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"

"github.com/KARTIKrocks/apikit/errors"
Expand Down Expand Up @@ -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 ---
Expand Down
12 changes: 11 additions & 1 deletion response/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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)
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions response/stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading