Skip to content

Commit cc56df6

Browse files
committed
Security assessment with Claude
1 parent 0fa41b9 commit cc56df6

13 files changed

Lines changed: 158 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Change Log
22

3+
## Unreleased
4+
5+
### Added
6+
* Configurable JWT token expiry (`TokenExpiry` config, default 24h)
7+
* Configurable error verbosity (`VerboseErrors` config, default true) to control whether database hints/details are returned to clients
8+
* Security headers middleware (`X-Content-Type-Options`, `X-Frame-Options`, `Strict-Transport-Security`)
9+
* Config startup validations: warn on empty JWT secret, reject CORS credentials with wildcard origins, warn on unlimited request body size
10+
11+
### Fixed
12+
* Fixed parallel test suites port collision (postgrest suite moved to port 8084)
13+
* SQL injection in `GetPolicies`, `GetDatabasePrivileges`, and `GetPrivileges` — switched from string concatenation to parameterized queries
14+
* JWT algorithm validation now pinned to HS256 (was accepting any HMAC variant)
15+
* CORS default changed from wildcard `*` to empty (must be explicitly configured)
16+
* CORS allowed headers restricted to specific list instead of wildcard
17+
* Admin `/sessions` endpoint now requires authentication
18+
* TLS minimum version enforced to TLS 1.2
19+
* Session keys now use SHA-256 hash instead of raw token strings
20+
* Removed hardcoded JWT secret from sample config
21+
22+
### Improved
23+
* Session improvements and tests
24+
325
## 0.5.0 - 2026-04-01
426

527
### Added

api/admin.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ func InitAdminRouter(apiHelper Helper) {
1616
adminURL := apiHelper.BaseAdminURL()
1717
admin_db := router.Group(adminURL, apiHelper.MiddlewareStd())
1818
admin_dbe := router.Group(adminURL, apiHelper.MiddlewareDBE())
19-
admin_nodb := router.Group(adminURL)
20-
2119
// ROLES
2220

2321
roles := admin_db.Group("/roles")
@@ -572,7 +570,7 @@ func InitAdminRouter(apiHelper Helper) {
572570

573571
// SESSIONS
574572

575-
admin_nodb.Handle("GET", "/sessions", func(c context.Context, w http.ResponseWriter, r heligo.Request) (int, error) {
573+
admin_dbe.Handle("GET", "/sessions", func(c context.Context, w http.ResponseWriter, r heligo.Request) (int, error) {
576574
stats := apiHelper.SessionStatistics()
577575
return heligo.WriteJSON(w, http.StatusOK, stats)
578576
})

api/login.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/http"
77
"strings"
8+
"time"
89

910
"github.com/sted/heligo"
1011
"github.com/sted/smoothdb/authn"
@@ -45,7 +46,7 @@ type AccessTokenResponse struct {
4546
WeakPassword *WeakPasswordError `json:"weak_password,omitempty"`
4647
}
4748

48-
func InitLoginRoute(apiHelper Helper, loginMode string, authURL string, jwtSecret string) {
49+
func InitLoginRoute(apiHelper Helper, loginMode string, authURL string, jwtSecret string, tokenExpiry int64) {
4950
api := apiHelper.GetRouter()
5051

5152
api.Handle("POST", "/token", func(c context.Context, w http.ResponseWriter, r heligo.Request) (int, error) {
@@ -60,8 +61,12 @@ func InitLoginRoute(apiHelper Helper, loginMode string, authURL string, jwtSecre
6061
case "db":
6162
err = database.VerifyAuthN(credentials.Email, credentials.Password)
6263
if err == nil {
63-
token, _ := authn.GenerateToken(credentials.Email, jwtSecret)
64-
resp = AccessTokenResponse{Token: token}
64+
var expiry time.Duration
65+
if tokenExpiry > 0 {
66+
expiry = time.Duration(tokenExpiry) * time.Second
67+
}
68+
token, _ := authn.GenerateToken(credentials.Email, jwtSecret, expiry)
69+
resp = AccessTokenResponse{Token: token, ExpiresIn: int(tokenExpiry)}
6570
return heligo.WriteJSON(w, http.StatusOK, resp)
6671
} else {
6772
return heligo.WriteJSON(w, http.StatusBadRequest, SmoothError{Subsystem: "auth", Message: err.Error()})

api/utils.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import (
2222
"github.com/sted/smoothdb/database"
2323
)
2424

25+
var verboseErrors = true
26+
27+
// SetVerboseErrors controls whether full database error details are returned to clients.
28+
func SetVerboseErrors(v bool) {
29+
verboseErrors = v
30+
}
31+
2532
// SmoothError is the generic struct for error reporting
2633
type SmoothError struct {
2734
Subsystem string `json:"subsystem"`
@@ -69,13 +76,16 @@ func WriteServerError(w http.ResponseWriter, err error) (int, error) {
6976
default:
7077
status = http.StatusInternalServerError
7178
}
72-
heligo.WriteJSON(w, status, SmoothError{
79+
smoothErr := SmoothError{
7380
Subsystem: "database",
7481
Message: dberr.Message,
7582
Code: dberr.Code,
76-
Hint: dberr.Hint,
77-
Details: dberr.Detail,
78-
})
83+
}
84+
if verboseErrors {
85+
smoothErr.Hint = dberr.Hint
86+
smoothErr.Details = dberr.Detail
87+
}
88+
heligo.WriteJSON(w, status, smoothErr)
7989
} else if errors.Is(err, pgx.ErrNoRows) {
8090
status = http.StatusNotFound
8191
heligo.WriteHeader(w, status)
@@ -84,7 +94,11 @@ func WriteServerError(w http.ResponseWriter, err error) (int, error) {
8494
heligo.WriteJSON(w, status, SmoothError{Subsystem: "auth", Message: err.Error()})
8595
} else {
8696
status = http.StatusInternalServerError
87-
heligo.WriteJSON(w, status, SmoothError{Message: err.Error()})
97+
msg := "internal server error"
98+
if verboseErrors {
99+
msg = err.Error()
100+
}
101+
heligo.WriteJSON(w, status, SmoothError{Message: msg})
88102
}
89103
return status, err
90104
}

authn/auth.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"net/http"
7+
"time"
78

89
"github.com/golang-jwt/jwt/v5"
910
"github.com/golang-jwt/jwt/v5/request"
@@ -37,7 +38,7 @@ func extractAuthHeader(req *http.Request) string {
3738

3839
func parseAuthHeader(tokenString string, secret string) (*Claims, error) {
3940
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
40-
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
41+
if token.Method.Alg() != "HS256" {
4142
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
4243
}
4344
return []byte(secret), nil
@@ -52,8 +53,18 @@ func parseAuthHeader(tokenString string, secret string) (*Claims, error) {
5253
}
5354
}
5455

55-
func GenerateToken(role, secret string) (string, error) {
56+
// GenerateToken creates a signed JWT for the given role.
57+
// If expiry > 0, the token will include exp and iat claims.
58+
// If expiry == 0, the token has no expiration (for testing or long-lived tokens).
59+
func GenerateToken(role, secret string, expiry ...time.Duration) (string, error) {
5660
claims := &Claims{Role: role}
61+
if len(expiry) > 0 && expiry[0] > 0 {
62+
now := time.Now()
63+
claims.RegisteredClaims = jwt.RegisteredClaims{
64+
ExpiresAt: jwt.NewNumericDate(now.Add(expiry[0])),
65+
IssuedAt: jwt.NewNumericDate(now),
66+
}
67+
}
5768
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
5869
return token.SignedString([]byte(secret))
5970
}

authn/middleware.go

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

33
import (
44
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
57
"fmt"
68
"net/http"
79

@@ -41,10 +43,12 @@ func (m middleware) acquireSession(ctx context.Context, r heligo.Request,
4143
return nil, nil, http.StatusUnauthorized, fmt.Errorf("unauthorized access")
4244
}
4345
dbname := getDBName(ctx, r)
44-
key := tokenString + "; "
46+
keyInput := tokenString + "; "
4547
if !forceDBE {
46-
key += dbname
48+
keyInput += dbname
4749
}
50+
h := sha256.Sum256([]byte(keyInput))
51+
key := hex.EncodeToString(h[:])
4852
session, isNewSession := m.SessionManager().getSession(key)
4953
if isNewSession {
5054
if tokenString != "" {

database/grants.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,13 @@ func GetDatabasePrivileges(ctx context.Context, dbname string) ([]Privilege, err
9797
privileges := []Privilege{}
9898

9999
query := privilegesDatabaseQuery
100+
var args []any
100101
if dbname != "" {
101-
query += " WHERE datname = '" + dbname + "'"
102+
query += " WHERE datname = $1"
103+
args = append(args, dbname)
102104
}
103105

104-
rows, err := conn.Query(ctx, query)
106+
rows, err := conn.Query(ctx, query, args...)
105107
if err != nil {
106108
return nil, err
107109
}
@@ -131,15 +133,17 @@ func GetPrivileges(ctx context.Context, targetType string, targetName string) ([
131133
privileges := []Privilege{}
132134
var query string
133135

136+
var args []any
134137
if targetType == "table" { // @@ table includes views etc for now
135138
query = privilegesRelationQuery
136139
if targetName != "" {
137140
schemaname, tablename := splitTableName(targetName)
138-
query += " AND c.relname = '" + tablename + "' AND n.nspname = '" + schemaname + "'"
141+
query += " AND c.relname = $1 AND n.nspname = $2"
142+
args = append(args, tablename, schemaname)
139143
}
140144
}
141145

142-
rows, err := conn.Query(ctx, query)
146+
rows, err := conn.Query(ctx, query, args...)
143147
if err != nil {
144148
return nil, err
145149
}

database/policies.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ func GetPolicies(ctx context.Context, ftablename string) ([]Policy, error) {
4545
policies := []Policy{}
4646
query := policyQuery
4747
schemaname, tablename := splitTableName(ftablename)
48-
query += " WHERE c.relname = '" + tablename + "' AND n.nspname = '" + schemaname + "'"
48+
query += " WHERE c.relname = $1 AND n.nspname = $2"
4949
query += " ORDER BY tablename"
50-
rows, err := conn.Query(ctx, query)
50+
rows, err := conn.Query(ctx, query, tablename, schemaname)
5151
if err != nil {
5252
return nil, err
5353
}
@@ -85,6 +85,9 @@ func CreatePolicy(ctx context.Context, policy *Policy) (*Policy, error) {
8585
create += quote(role)
8686
}
8787
}
88+
// USING and WITH CHECK are SQL expressions by design (like PostgREST RLS policies)
89+
// and cannot be parameterized. This is safe because CreatePolicy is only reachable
90+
// via the admin API which requires EnableAdminRoute + authentication.
8891
if policy.Using != nil {
8992
create += " USING (" + *policy.Using + ")"
9093
}

server/config.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type Config struct {
4242
Plugins []string `comment:"Ordered list of plugins (default: [])"`
4343
ReadTimeout int64 `comment:"The maximum duration (seconds) for reading the entire request, including the body (default: 60)"`
4444
WriteTimeout int64 `comment:"The maximum duration before timing out writes of the response (default: 60)"`
45+
TokenExpiry int64 `comment:"JWT token expiry in seconds (default: 86400 = 24h, 0 for no expiry)"`
46+
VerboseErrors bool `comment:"Return full database error details (hint, detail) to clients (default: true)"`
4547
RequestMaxBytes int64 `comment:"Max bytes allowed in requests, to limit the size of incoming request bodies (default: 1M, 0 for unlimited)"`
4648
Database database.Config `comment:"Database configuration"`
4749
Logging logging.Config `comment:"Logging configuration"`
@@ -63,13 +65,15 @@ func defaultConfig() *Config {
6365
BaseAPIURL: "/api",
6466
ShortAPIURL: false,
6567
BaseAdminURL: "/admin",
66-
CORSAllowedOrigins: []string{"*"},
68+
CORSAllowedOrigins: []string{},
6769
CORSAllowCredentials: false,
6870
EnableDebugRoute: false,
6971
PluginDir: "./_plugins",
7072
Plugins: []string{},
7173
ReadTimeout: 60,
7274
WriteTimeout: 60,
75+
TokenExpiry: 86400,
76+
VerboseErrors: true,
7377
RequestMaxBytes: 1024 * 1024,
7478
Database: *database.DefaultConfig(),
7579
Logging: *logging.DefaultConfig(),
@@ -226,6 +230,19 @@ func checkConfig(cfg *Config) error {
226230
fmt.Println("Warning: 'ShortAPIURL' requires a single db in 'Database.AllowedDatabases'")
227231
cfg.ShortAPIURL = false
228232
}
233+
if cfg.LoginMode != "none" && cfg.JWTSecret == "" {
234+
fmt.Println("Warning: 'JWTSecret' is empty while authentication is enabled (LoginMode:", cfg.LoginMode+")")
235+
}
236+
if cfg.CORSAllowCredentials {
237+
for _, origin := range cfg.CORSAllowedOrigins {
238+
if origin == "*" {
239+
return fmt.Errorf("invalid CORS configuration: 'CORSAllowCredentials' cannot be true when 'CORSAllowedOrigins' includes '*'")
240+
}
241+
}
242+
}
243+
if cfg.RequestMaxBytes == 0 {
244+
fmt.Println("Warning: 'RequestMaxBytes' is 0 (unlimited request body size)")
245+
}
229246
canContinue, err := database.CheckDatabase(&cfg.Database)
230247
if err != nil {
231248
return err

server/cors.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func (s *Server) initCORS() {
2222
http.MethodPatch,
2323
http.MethodDelete,
2424
},
25-
AllowedHeaders: []string{"*"},
25+
AllowedHeaders: []string{"Authorization", "Content-Type", "Accept", "Prefer", "Accept-Profile", "Content-Profile"},
2626
AllowCredentials: s.Config.CORSAllowCredentials,
2727
MaxAge: 86400,
2828
})

0 commit comments

Comments
 (0)