Skip to content

Commit b126e09

Browse files
authored
Merge pull request #6 from StudioLambda/fix/low-security-vulnerabilities
fix: low-severity security hardening across all modules
2 parents 1645261 + 981f970 commit b126e09

25 files changed

Lines changed: 540 additions & 143 deletions

File tree

contract/request/param.go

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package request
22

3-
import "net/http"
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strconv"
7+
)
48

5-
// Param retrieves a path parameter value by name from the HTTP request.
6-
// This uses Go's built-in PathValue method which extracts values from
7-
// URL path patterns like "/users/{id}" where {id} is the parameter name.
9+
// Param retrieves a path parameter value by name from the
10+
// HTTP request. This uses Go's built-in PathValue method
11+
// which extracts values from URL path patterns like
12+
// "/users/{id}" where {id} is the parameter name.
813
//
914
// Parameters:
1015
// - r: The HTTP request containing the path parameters
@@ -15,10 +20,11 @@ func Param(r *http.Request, name string) string {
1520
return r.PathValue(name)
1621
}
1722

18-
// ParamOr retrieves a path parameter value by name, returning a default
19-
// value if the parameter doesn't exist or is empty. This is useful for
20-
// providing fallback values when path parameters are optional or when
21-
// you want to handle missing parameters gracefully.
23+
// ParamOr retrieves a path parameter value by name,
24+
// returning a default value if the parameter doesn't exist
25+
// or is empty. This is useful for providing fallback values
26+
// when path parameters are optional or when you want to
27+
// handle missing parameters gracefully.
2228
//
2329
// Parameters:
2430
// - r: The HTTP request containing the path parameters
@@ -33,3 +39,57 @@ func ParamOr(r *http.Request, name string, fallback string) string {
3339

3440
return fallback
3541
}
42+
43+
// ParamInt retrieves a path parameter by name and parses
44+
// it as an integer. This prevents injection via malformed
45+
// numeric path parameters by validating that the value is
46+
// a well-formed integer.
47+
//
48+
// Parameters:
49+
// - r: The HTTP request containing the path parameters
50+
// - k: The name of the path parameter to parse
51+
//
52+
// Returns the parsed integer value and any parsing error.
53+
// Returns an error if the parameter is empty or is not
54+
// a valid integer string.
55+
func ParamInt(r *http.Request, k string) (int, error) {
56+
raw := Param(r, k)
57+
58+
if raw == "" {
59+
return 0, fmt.Errorf("path parameter %q is empty", k)
60+
}
61+
62+
value, err := strconv.Atoi(raw)
63+
64+
if err != nil {
65+
return 0, fmt.Errorf(
66+
"path parameter %q is not a valid integer: %w",
67+
k, err,
68+
)
69+
}
70+
71+
return value, nil
72+
}
73+
74+
// ParamIntOr retrieves a path parameter by name and parses
75+
// it as an integer, returning the provided fallback value
76+
// if the parameter is empty or cannot be parsed. This is
77+
// useful when a numeric path parameter is optional or when
78+
// a sensible default exists.
79+
//
80+
// Parameters:
81+
// - r: The HTTP request containing the path parameters
82+
// - k: The name of the path parameter to parse
83+
// - d: The fallback value to return on failure
84+
//
85+
// Returns the parsed integer if valid, otherwise the
86+
// fallback value.
87+
func ParamIntOr(r *http.Request, k string, d int) int {
88+
value, err := ParamInt(r, k)
89+
90+
if err != nil {
91+
return d
92+
}
93+
94+
return value
95+
}

contract/request/query.go

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package request
22

3-
import "net/http"
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strconv"
7+
)
48

5-
// Query retrieves a query parameter value by name from the HTTP request URL.
6-
// This extracts values from the URL query string like "?name=value&other=test"
7-
// where the parameter name matches the provided key.
9+
// Query retrieves a query parameter value by name from the
10+
// HTTP request URL. This extracts values from the URL query
11+
// string like "?name=value&other=test" where the parameter
12+
// name matches the provided key.
813
//
914
// Parameters:
1015
// - r: The HTTP request containing the URL with query parameters
@@ -15,9 +20,10 @@ func Query(r *http.Request, name string) string {
1520
return r.URL.Query().Get(name)
1621
}
1722

18-
// HasQuery checks if a query parameter exists in the HTTP request URL,
19-
// regardless of its value. This is useful for distinguishing between
20-
// a parameter that doesn't exist and one that exists but has an empty value.
23+
// HasQuery checks if a query parameter exists in the HTTP
24+
// request URL, regardless of its value. This is useful for
25+
// distinguishing between a parameter that doesn't exist and
26+
// one that exists but has an empty value.
2127
//
2228
// Parameters:
2329
// - r: The HTTP request containing the URL with query parameters
@@ -28,10 +34,12 @@ func HasQuery(r *http.Request, name string) bool {
2834
return r.URL.Query().Has(name)
2935
}
3036

31-
// QueryOr retrieves a query parameter value by name, returning a default
32-
// value if the parameter doesn't exist. Note that if the parameter exists
33-
// but has an empty value, the empty value is returned, not the default.
34-
// This is useful for providing fallback values for optional parameters.
37+
// QueryOr retrieves a query parameter value by name,
38+
// returning a default value if the parameter doesn't exist.
39+
// Note that if the parameter exists but has an empty value,
40+
// the empty value is returned, not the default. This is
41+
// useful for providing fallback values for optional
42+
// parameters.
3543
//
3644
// Parameters:
3745
// - r: The HTTP request containing the URL with query parameters
@@ -46,3 +54,59 @@ func QueryOr(r *http.Request, name string, fallback string) string {
4654

4755
return fallback
4856
}
57+
58+
// QueryInt retrieves a query parameter by name and parses
59+
// it as an integer. This prevents injection via malformed
60+
// numeric query parameters by validating that the value is
61+
// a well-formed integer.
62+
//
63+
// Parameters:
64+
// - r: The HTTP request containing the URL with query
65+
// parameters
66+
// - k: The name of the query parameter to parse
67+
//
68+
// Returns the parsed integer value and any parsing error.
69+
// Returns an error if the parameter is missing or is not
70+
// a valid integer string.
71+
func QueryInt(r *http.Request, k string) (int, error) {
72+
raw := Query(r, k)
73+
74+
if raw == "" {
75+
return 0, fmt.Errorf("query parameter %q is empty", k)
76+
}
77+
78+
value, err := strconv.Atoi(raw)
79+
80+
if err != nil {
81+
return 0, fmt.Errorf(
82+
"query parameter %q is not a valid integer: %w",
83+
k, err,
84+
)
85+
}
86+
87+
return value, nil
88+
}
89+
90+
// QueryIntOr retrieves a query parameter by name and parses
91+
// it as an integer, returning the provided fallback value
92+
// if the parameter is missing or cannot be parsed. This is
93+
// useful when a numeric query parameter is optional or when
94+
// a sensible default exists (e.g., pagination page numbers).
95+
//
96+
// Parameters:
97+
// - r: The HTTP request containing the URL with query
98+
// parameters
99+
// - k: The name of the query parameter to parse
100+
// - d: The fallback value to return on failure
101+
//
102+
// Returns the parsed integer if valid, otherwise the
103+
// fallback value.
104+
func QueryIntOr(r *http.Request, k string, d int) int {
105+
value, err := QueryInt(r, k)
106+
107+
if err != nil {
108+
return d
109+
}
110+
111+
return value
112+
}

contract/response/static.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,21 @@ import (
1111
"text/template"
1212
)
1313

14-
// Raw writes raw byte data to the response writer with the specified status code.
15-
// It does not set any Content-Type header, allowing the caller full control over
16-
// the response format. This is the most basic response function that other
17-
// response functions build upon.
14+
// Raw writes raw byte data to the response writer with
15+
// the specified status code. If no Content-Type header has
16+
// been explicitly set on the response writer, it defaults
17+
// to application/octet-stream to ensure a safe default
18+
// content type is always present.
1819
//
1920
// Parameters:
2021
// - w: The HTTP response writer
2122
// - status: The HTTP status code to set
2223
// - data: The raw byte data to write to the response
2324
func Raw(w http.ResponseWriter, status int, data []byte) error {
25+
if w.Header().Get("Content-Type") == "" {
26+
w.Header().Set("Content-Type", "application/octet-stream")
27+
}
28+
2429
w.WriteHeader(status)
2530

2631
_, err := w.Write(data)
@@ -88,9 +93,11 @@ func StringTemplate(w http.ResponseWriter, status int, tmpl template.Template, d
8893
return tmpl.Execute(w, data)
8994
}
9095

91-
// HTML writes HTML content to the response writer with the appropriate
92-
// Content-Type header for HTML (text/html). This is used for serving
93-
// static HTML content or HTML strings that have been pre-generated.
96+
// HTML writes HTML content to the response writer with the
97+
// appropriate Content-Type header for HTML content
98+
// (text/html; charset=utf-8). The charset is explicitly
99+
// set to prevent XSS attacks via charset sniffing in
100+
// older browsers that may otherwise guess the encoding.
94101
//
95102
// Parameters:
96103
// - w: The HTTP response writer

contract/response/stream.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@ import (
99
// implement the http.Flusher interface, which is required for streaming responses.
1010
var ErrNonFlushableWriter = errors.New("non-flushable response writer")
1111

12-
// Stream sends data from a channel to an HTTP client using streaming.
13-
// It flushes data as it becomes available and returns when the channel
14-
// is closed or the request context is canceled. Returns
15-
// [ErrNonFlushableWriter] if the writer does not support flushing.
12+
// Stream sends data from a channel to an HTTP client using
13+
// streaming. It flushes data as it becomes available and
14+
// returns when the channel is closed or the request context
15+
// is canceled. Returns [ErrNonFlushableWriter] if the writer
16+
// does not support flushing.
17+
//
18+
// If no Content-Type header has been explicitly set on the
19+
// response writer, it defaults to application/octet-stream
20+
// to ensure a safe default content type is always present.
1621
func Stream(w http.ResponseWriter, r *http.Request, c <-chan []byte) error {
22+
if w.Header().Get("Content-Type") == "" {
23+
w.Header().Set("Content-Type", "application/octet-stream")
24+
}
25+
1726
w.Header().Set("Cache-Control", "no-cache")
1827
w.Header().Set("Connection", "keep-alive")
1928

framework/cache/memory.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cache
22

33
import (
44
"context"
5-
"fmt"
65
"sync"
76
"time"
87

@@ -28,14 +27,15 @@ func NewMemory(expiration time.Duration, cleanup time.Duration) *Memory {
2827
}
2928
}
3029

31-
// Get retrieves the value for the given key from the in-memory store.
32-
// Returns contract.ErrCacheKeyNotFound wrapped with the key name
33-
// when the key does not exist or has expired.
30+
// Get retrieves the value for the given key from the in-memory
31+
// store. Returns contract.ErrCacheKeyNotFound when the key does
32+
// not exist or has expired. The key is intentionally omitted from
33+
// the error to prevent cache key enumeration attacks.
3434
func (memory *Memory) Get(_ context.Context, key string) (any, error) {
3535
val, found := memory.store.Get(key)
3636

3737
if !found {
38-
return nil, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
38+
return nil, contract.ErrCacheKeyNotFound
3939
}
4040

4141
return val, nil
@@ -98,7 +98,7 @@ func (memory *Memory) Increment(ctx context.Context, key string, by int64) (int6
9898
defer memory.mux.Unlock()
9999

100100
if found, _ := memory.Has(ctx, key); !found {
101-
return 0, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
101+
return 0, contract.ErrCacheKeyNotFound
102102
}
103103

104104
return memory.store.IncrementInt64(key, by)
@@ -112,7 +112,7 @@ func (memory *Memory) Decrement(ctx context.Context, key string, by int64) (int6
112112
defer memory.mux.Unlock()
113113

114114
if found, _ := memory.Has(ctx, key); !found {
115-
return 0, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
115+
return 0, contract.ErrCacheKeyNotFound
116116
}
117117

118118
return memory.store.DecrementInt64(key, by)

framework/cache/redis.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7-
"fmt"
87
"time"
98

109
"github.com/redis/go-redis/v9"
@@ -32,13 +31,15 @@ func NewRedisFrom(client *redis.Client) *RedisClient {
3231
return (*RedisClient)(client)
3332
}
3433

35-
// Get retrieves a value by key. Returns contract.ErrCacheKeyNotFound
36-
// wrapped with the key name when the key does not exist.
34+
// Get retrieves a value by key. Returns
35+
// contract.ErrCacheKeyNotFound when the key does not exist.
36+
// The key is intentionally omitted from the error to prevent
37+
// cache key enumeration attacks.
3738
func (client *RedisClient) Get(ctx context.Context, key string) (any, error) {
3839
value, err := (*redis.Client)(client).Get(ctx, key).Result()
3940

4041
if errors.Is(err, redis.Nil) {
41-
return nil, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
42+
return nil, contract.ErrCacheKeyNotFound
4243
}
4344

4445
if err != nil {
@@ -76,7 +77,7 @@ func (client *RedisClient) Pull(ctx context.Context, key string) (value any, err
7677
encoded, err := (*redis.Client)(client).GetDel(ctx, key).Result()
7778

7879
if errors.Is(err, redis.Nil) {
79-
return nil, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
80+
return nil, contract.ErrCacheKeyNotFound
8081
}
8182

8283
if err != nil {

0 commit comments

Comments
 (0)