Skip to content

Commit 1645261

Browse files
authored
Merge pull request #5 from StudioLambda/fix/medium-security-vulnerabilities
fix: address all MEDIUM tier security vulnerabilities (#13-#32)
2 parents 337d578 + 0d61af4 commit 1645261

23 files changed

Lines changed: 814 additions & 155 deletions

File tree

.github/workflows/framework.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ on:
55
branches: ["main"]
66
paths:
77
- .github/workflows/framework.yml
8+
- contract/**
89
- framework/**
910
pull_request:
1011
branches: ["main"]
1112
paths:
1213
- .github/workflows/framework.yml
14+
- contract/**
1315
- framework/**
1416

1517
jobs:
@@ -28,6 +30,9 @@ jobs:
2830
go-version-file: "framework/go.mod"
2931
cache-dependency-path: "framework/go.sum"
3032

33+
- name: Link local modules
34+
run: go mod edit -replace github.com/studiolambda/cosmos/contract=../contract -replace github.com/studiolambda/cosmos/problem=../problem -replace github.com/studiolambda/cosmos/router=../router
35+
3136
- name: Download Dependencies
3237
run: go mod download
3338

AGENTS.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,9 @@ cd contract && go generate ./...
3131

3232
Always run tests from workspace root for proper module resolution. Each module has its own go.mod. Use full import paths: `github.com/studiolambda/cosmos/contract`
3333

34-
## Code Style
34+
## Framework Patterns
3535

36-
- Standard Go formatting with gofmt
37-
- Descriptive names, early returns, single-purpose functions
38-
- Doc comments starting with name being documented
39-
- Always check errors with errors.Is() and errors.As()
40-
- Table-driven tests with testify assertions
41-
42-
### Framework Patterns
43-
44-
Error-returning handlers:
36+
Error-returning handlers (unlike stdlib, Cosmos handlers return errors):
4537
```go
4638
func handler(w http.ResponseWriter, r *http.Request) error {
4739
return response.JSON(w, http.StatusOK, data)

contract/mock/session.go

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contract/request/body.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,12 @@ func LimitedString(r *http.Request, maxSize int64) (string, error) {
6666
return string(body), nil
6767
}
6868

69-
// JSON decodes JSON data from the request body into a value of type T.
70-
// It uses a streaming decoder for memory efficiency. The type parameter
71-
// T should match the expected JSON structure.
69+
// JSON decodes JSON data from the request body into a value of
70+
// type T. It uses a streaming decoder for memory efficiency. The
71+
// type parameter T should match the expected JSON structure.
72+
//
73+
// Unknown fields in the JSON input are silently ignored. Use
74+
// [StrictJSON] if unknown fields should cause an error.
7275
//
7376
// WARNING: This function decodes without any body size limit.
7477
// Prefer [LimitedJSON] or apply [http.MaxBytesReader] in a
@@ -81,6 +84,27 @@ func JSON[T any](r *http.Request) (value T, err error) {
8184
return value, nil
8285
}
8386

87+
// StrictJSON decodes JSON data from the request body into a value
88+
// of type T, rejecting any fields not present in T's definition.
89+
// This is useful for APIs that require exact schema compliance
90+
// and want to surface typos or unsupported fields to callers.
91+
//
92+
// For a lenient variant that ignores unknown fields, use [JSON].
93+
//
94+
// WARNING: This function decodes without any body size limit.
95+
// Prefer [StrictLimitedJSON] or apply [http.MaxBytesReader] in a
96+
// middleware to prevent memory exhaustion from oversized requests.
97+
func StrictJSON[T any](r *http.Request) (value T, err error) {
98+
decoder := json.NewDecoder(r.Body)
99+
decoder.DisallowUnknownFields()
100+
101+
if err := decoder.Decode(&value); err != nil {
102+
return value, err
103+
}
104+
105+
return value, nil
106+
}
107+
84108
// LimitedJSON decodes JSON data from the request body into a value
85109
// of type T, reading at most maxSize bytes. This prevents
86110
// denial-of-service attacks via oversized JSON payloads. Pass -1
@@ -99,6 +123,25 @@ func LimitedJSON[T any](r *http.Request, maxSize int64) (value T, err error) {
99123
return value, nil
100124
}
101125

126+
// StrictLimitedJSON decodes JSON data from the request body into
127+
// a value of type T, reading at most maxSize bytes and rejecting
128+
// unknown fields. Pass -1 to use [DefaultMaxBodySize].
129+
func StrictLimitedJSON[T any](r *http.Request, maxSize int64) (value T, err error) {
130+
if maxSize < 0 {
131+
maxSize = DefaultMaxBodySize
132+
}
133+
134+
limited := io.LimitReader(r.Body, maxSize+1)
135+
decoder := json.NewDecoder(limited)
136+
decoder.DisallowUnknownFields()
137+
138+
if err := decoder.Decode(&value); err != nil {
139+
return value, err
140+
}
141+
142+
return value, nil
143+
}
144+
102145
// XML decodes XML data from the request body into a value of type T.
103146
// It uses a streaming decoder for memory efficiency. The type parameter
104147
// T should have appropriate xml struct tags or implement [xml.Unmarshaler].

contract/session.go

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,62 +11,87 @@ type sessionKey struct{}
1111
// SessionKey is the context key used to store and retrieve the session from a context.Context.
1212
var SessionKey = sessionKey{}
1313

14-
// Session represents a user session with data storage and lifecycle management capabilities.
15-
// It provides methods to store, retrieve, and manage session data, as well as control
16-
// session expiration and regeneration for security purposes.
14+
// Session represents a user session with data storage and lifecycle
15+
// management capabilities. It provides methods to store, retrieve,
16+
// and manage session data, as well as control session expiration
17+
// and regeneration for security purposes.
1718
type Session interface {
18-
// SessionID returns the current session identifier. This may differ from the original
19-
// session ID if the session has been regenerated.
19+
// SessionID returns the current session identifier. This may
20+
// differ from the original session ID if the session has been
21+
// regenerated.
2022
SessionID() string
2123

22-
// OriginalSessionID returns the session ID that was originally assigned to this session.
23-
// This remains constant even if the session is regenerated.
24+
// OriginalSessionID returns the session ID that was originally
25+
// assigned to this session. This remains constant even if the
26+
// session is regenerated.
2427
OriginalSessionID() string
2528

26-
// Get retrieves a value from the session by key. It returns the value and a boolean
27-
// indicating whether the key exists in the session. The value can be of any type.
29+
// Get retrieves a value from the session by key. It returns
30+
// the value and a boolean indicating whether the key exists
31+
// in the session. The value can be of any type.
2832
Get(key string) (any, bool)
2933

30-
// Put stores a value in the session associated with the given key. If the key already
31-
// exists, its value is overwritten.
34+
// Put stores a value in the session associated with the given
35+
// key. If the key already exists, its value is overwritten.
36+
//
37+
// WARNING: When storing authentication-related state, callers
38+
// MUST call Regenerate immediately after to prevent session
39+
// fixation attacks.
3240
Put(key string, value any)
3341

34-
// Delete removes the value associated with the given key from the session.
35-
// If the key does not exist, this operation is a no-op.
42+
// Delete removes the value associated with the given key from
43+
// the session. If the key does not exist, this is a no-op.
3644
Delete(key string)
3745

38-
// Extend updates the session's expiration time to the specified time. This is useful
39-
// for extending a session's lifetime during active use.
46+
// Extend updates the session's expiration time to the specified
47+
// time. This is useful for extending a session's lifetime
48+
// during active use.
4049
Extend(expiresAt time.Time)
4150

42-
// Regenerate creates a new session ID and associates it with this session.
43-
// This is commonly used after authentication to prevent session
44-
// fixation attacks. It returns an error if the regeneration process fails.
51+
// Regenerate creates a new session ID and associates it with
52+
// this session. This is commonly used after authentication to
53+
// prevent session fixation attacks. It returns an error if
54+
// the regeneration process fails.
55+
//
56+
// WARNING: This method MUST be called after any authentication
57+
// state change (login, logout, privilege escalation).
4558
Regenerate() error
4659

47-
// Clear removes all data from the session while maintaining the session itself.
60+
// Clear removes all data from the session while maintaining
61+
// the session itself.
4862
Clear()
4963

64+
// CreatedAt returns the absolute time the session was first
65+
// created. This value never changes, even if the session is
66+
// regenerated or extended.
67+
CreatedAt() time.Time
68+
5069
// ExpiresAt returns the time at which the session will expire.
5170
ExpiresAt() time.Time
5271

53-
// HasExpired returns true if the current time is past the session's expiration time.
72+
// HasExpired returns true if the current time is past the
73+
// session's expiration time.
5474
HasExpired() bool
5575

56-
// ExpiresSoon returns true if the session will expire within the specified duration
57-
// from the current time. This is useful for triggering session renewal prompts.
76+
// ExpiresSoon returns true if the session will expire within
77+
// the specified duration from the current time. This is useful
78+
// for triggering session renewal prompts.
5879
ExpiresSoon(delta time.Duration) bool
5980

60-
// HasChanged returns true if the session data has been modified since it was loaded.
61-
// This is useful for determining whether the session needs to be persisted.
81+
// HasChanged returns true if the session data has been modified
82+
// since it was loaded. This is useful for determining whether
83+
// the session needs to be persisted.
6284
HasChanged() bool
6385

64-
// HasRegenerated returns true if the session has been regenerated (i.e., the session ID
65-
// has changed). This is useful for sending updated session identifiers to the client.
86+
// HasRegenerated returns true if the session has been
87+
// regenerated (i.e., the session ID has changed). This is
88+
// useful for sending updated session identifiers to the
89+
// client.
6690
HasRegenerated() bool
6791

68-
// MarkAsUnchanged sets the session as if nothing has changed, therefore avoiding saving
69-
// the session when the request finishes.
92+
// MarkAsUnchanged sets the session as if nothing has changed,
93+
// therefore avoiding saving the session when the request
94+
// finishes.
7095
MarkAsUnchanged()
7196
}
7297

framework/cache/memory.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,16 @@ func (memory *Memory) Decrement(ctx context.Context, key string, by int64) (int6
118118
return memory.store.DecrementInt64(key, by)
119119
}
120120

121-
// Remember retrieves the cached value for the given key, or computes
122-
// and stores it if the key is not found. The compute function is only
123-
// called on a cache miss.
121+
// Remember retrieves the cached value for the given key, or
122+
// computes and stores it if the key is not found. The compute
123+
// function is only called on a cache miss.
124+
//
125+
// WARNING: This method is not protected against thundering herd
126+
// (cache stampede). Under high concurrency, multiple goroutines
127+
// may observe a cache miss simultaneously and all invoke the
128+
// compute function. For expensive computations, callers should
129+
// use golang.org/x/sync/singleflight to deduplicate concurrent
130+
// calls for the same key.
124131
func (memory *Memory) Remember(ctx context.Context, key string, ttl time.Duration, compute func() (any, error)) (any, error) {
125132
val, err := memory.Get(ctx, key)
126133

framework/cache/redis.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,17 @@ func (client *RedisClient) Decrement(ctx context.Context, key string, by int64)
105105
return (*redis.Client)(client).DecrBy(ctx, key, by).Result()
106106
}
107107

108-
// Remember retrieves the cached value for key, or computes and stores
109-
// it with the given TTL on a cache miss. Non-"key not found" errors
110-
// from Get are returned immediately without calling compute.
108+
// Remember retrieves the cached value for key, or computes and
109+
// stores it with the given TTL on a cache miss. Non-"key not
110+
// found" errors from Get are returned immediately without calling
111+
// compute.
112+
//
113+
// WARNING: This method is not protected against thundering herd
114+
// (cache stampede). Under high concurrency, multiple goroutines
115+
// may observe a cache miss simultaneously and all invoke the
116+
// compute function. For expensive computations, callers should
117+
// use golang.org/x/sync/singleflight to deduplicate concurrent
118+
// calls for the same key.
111119
func (client *RedisClient) Remember(ctx context.Context, key string, ttl time.Duration, compute func() (any, error)) (any, error) {
112120
val, err := client.Get(ctx, key)
113121

framework/database/sql.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ type SQL struct {
2525
raw *sqlx.DB // needed for transactions
2626
}
2727

28-
// NewSQL connects to the database using the given driver name and DSN,
29-
// returning a ready-to-use SQL instance or an error if the connection
30-
// cannot be established.
28+
// NewSQL connects to the database using the given driver name and
29+
// DSN, returning a ready-to-use SQL instance or an error if the
30+
// connection cannot be established.
31+
//
32+
// WARNING: No default query timeout is applied. Long-running or
33+
// runaway queries will block indefinitely unless the caller
34+
// passes a context.Context with a deadline or timeout. For
35+
// production use, configure statement timeouts at the database
36+
// driver level (e.g., statement_timeout for PostgreSQL) or wrap
37+
// all query contexts with context.WithTimeout.
3138
func NewSQL(driver string, dsn string) (*SQL, error) {
3239
db, err := sqlx.Connect(driver, dsn)
3340

@@ -41,6 +48,9 @@ func NewSQL(driver string, dsn string) (*SQL, error) {
4148
// NewSQLFrom wraps an existing sqlx.DB connection in a SQL instance.
4249
// This is useful when you need to configure the connection pool or
4350
// driver options before handing it to the framework.
51+
//
52+
// WARNING: No default query timeout is applied. See [NewSQL] for
53+
// recommendations on configuring query timeouts.
4454
func NewSQLFrom(db *sqlx.DB) *SQL {
4555
return &SQL{db: db, raw: db}
4656
}

framework/event/amqp.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,6 @@ func (broker *AMQPBroker) Subscribe(
227227
wg := sync.WaitGroup{}
228228

229229
wg.Go(func() {
230-
defer wg.Done()
231-
232230
for delivery := range deliveries {
233231
handler(func(dest any) error {
234232
return json.Unmarshal(delivery.Body, dest)

framework/event/mqtt.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,12 @@ func (broker *MQTTBroker) Subscribe(
351351
broker.mu.Unlock()
352352

353353
if shouldUnsubscribe {
354-
_, err := broker.client.Unsubscribe(ctx, &paho.Unsubscribe{
355-
Topics: []string{topic},
356-
})
354+
_, err := broker.client.Unsubscribe(
355+
context.WithoutCancel(ctx),
356+
&paho.Unsubscribe{
357+
Topics: []string{topic},
358+
},
359+
)
357360

358361
return err
359362
}

0 commit comments

Comments
 (0)