Skip to content

Commit 66360c9

Browse files
committed
Merge branch 'security-hardening'
Security review fixes: SQL-injection sinks, SET ROLE quoting, empty-JWT-secret hard-fail, TLS fail-closed, 0600 config file, plus regression tests and docs.
2 parents daee71e + c48e3cb commit 66360c9

13 files changed

Lines changed: 275 additions & 19 deletions

CHANGELOG.md

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

3+
## Unreleased
4+
5+
### Security
6+
* **SQL injection** — closed four sinks where request tokens were interpolated into SQL with bare quotes instead of being escaped/parameterized (a backslash escape in the parser let a client smuggle a literal `'`/`"`): the `select` alias, embedded-resource filter values, full-text-search config arguments, and JSON-path members. Top-level filter values and identifiers were already parameterized and unaffected.
7+
* **`SET ROLE`** — the JWT `role` claim is now quoted as an identifier so it cannot break out of the statement.
8+
* **Empty JWT secret** — the server now refuses to start when authentication is enabled and `JWTSecret` is empty (an empty HMAC key lets anyone forge tokens); previously only a warning. Debug mode auto-generates a random secret.
9+
* **TLS fail-closed** — a configured certificate that fails to load is now a fatal startup error instead of silently serving plaintext HTTP.
10+
* **Config file permissions** — written `0600` (was `0777`), since it holds the JWT secret and the database password.
11+
312
## 0.8.0 - 2026-07-07
413

514
### Added

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ SmoothDB supports two authentication methods via the `LoginMode` configuration o
133133

134134
- No explicit authorization step is needed beyond providing the JWT token with each request
135135
- Both authentication methods require email and password for token generation through the `/token` endpoint
136+
- `JWTSecret` must be set whenever authentication is enabled (`LoginMode` other than `none`): the server refuses to start with an empty secret, because an empty HMAC key would let anyone forge a token for any role. Set it in the configuration file or via the `SMOOTHDB_JWT_SECRET` environment variable. In debug mode (`SMOOTHDB_DEBUG=true`) a random secret is generated automatically for the run.
137+
- When TLS is configured (`CertFile`/`KeyFile`), a certificate that fails to load is a fatal startup error - SmoothDB will not silently fall back to plaintext HTTP.
138+
- The configuration file holds secrets (the JWT secret and the database URL with its password); it is written with `0600` permissions. Keep it that way and out of version control.
136139

137140
We will omit the Authorization header in the following examples.
138141

@@ -407,6 +410,14 @@ GET /api/testdb/employees?id=start.1&manager_id=recurse.3 HTTP/1.1
407410

408411
Returns row `id=1` and its descendants up to 3 levels deep, following `manager_id → id`. Use `recurse.all` (or bare `recurse`) for unlimited depth (capped by `MaxRecursiveDepth`), and `after` instead of `start` to exclude the seed row.
409412

413+
**Walking upward (`recurse!up`).** By default recursion follows the FK toward descendants. Add the `!up` hint to walk toward ancestors instead - the chain from a node up to the root:
414+
415+
```http
416+
GET /api/testdb/employees?id=start.5&manager_id=recurse!up.all HTTP/1.1 # employee 5 and its manager chain
417+
```
418+
419+
`recurse!up` is single-table only; it is rejected with `via()`, where the same reversal is expressed by swapping the edge's source/target columns.
420+
410421
**Result vs. traversal filters.** A plain filter restricts the *result*: the whole subtree is walked, then non-matching rows are dropped. Prefix a filter with `walk.` to prune the *traversal* instead - a non-matching node, and everything beyond it, is skipped:
411422

412423
```http

config/config.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,17 @@ func SaveConfig[T any](config T, configFile string) error {
9696
if err != nil {
9797
return fmt.Errorf("error writing the configuration file (%w)", err)
9898
}
99-
err = os.WriteFile(configFile, b, 0777)
99+
// The config file holds secrets (JWT secret, DB URL with password), so it
100+
// must not be group/world readable or writable.
101+
err = os.WriteFile(configFile, b, 0600)
100102
if err != nil {
101103
return fmt.Errorf("error writing the configuration file (%w)", err)
102104
}
105+
// WriteFile does not change the mode of a pre-existing file, so tighten it
106+
// explicitly to remediate files previously created world-accessible.
107+
if err = os.Chmod(configFile, 0600); err != nil {
108+
return fmt.Errorf("error securing the configuration file (%w)", err)
109+
}
103110
return nil
104111
}
105112

config/config_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// The config file holds secrets (JWT secret and the DB URL with its password),
10+
// so it must not be world-readable/writable. Regression test for the previous
11+
// 0777 mode.
12+
func TestSaveConfigPermissions(t *testing.T) {
13+
dir := t.TempDir()
14+
path := filepath.Join(dir, "config.jsonc")
15+
16+
type cfg struct {
17+
JWTSecret string `comment:"secret"`
18+
}
19+
if err := SaveConfig(&cfg{JWTSecret: "s3cr3t"}, path); err != nil {
20+
t.Fatalf("SaveConfig: %v", err)
21+
}
22+
23+
info, err := os.Stat(path)
24+
if err != nil {
25+
t.Fatalf("stat: %v", err)
26+
}
27+
if perm := info.Mode().Perm(); perm != 0o600 {
28+
t.Errorf("config file mode = %o, want 600 (secrets must not be group/world accessible)", perm)
29+
}
30+
}

database/conn.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ func PrepareConnection(ctx context.Context, conn *DbPoolConn, role string, claim
4343

4444
// set role and other configurations only on the first acquire
4545
if newAcquire && role != "" {
46-
_, err := conn.Exec(ctx, "SET ROLE "+role)
46+
// role comes from the JWT claim; quote it as an identifier so it cannot
47+
// break out of the SET ROLE statement (it is otherwise unvalidated).
48+
_, err := conn.Exec(ctx, "SET ROLE "+quote(role))
4749
if err != nil {
4850
return err
4951
}

database/injection_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package database
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
)
7+
8+
// These are regression tests for SQL-injection sinks where user-controlled
9+
// tokens were interpolated into SQL text with bare quotes instead of being
10+
// escaped/parameterized. The request parser lets a client smuggle a literal
11+
// ' or " (and even separators) into a token via a backslash escape, so every
12+
// interpolation site must quote/escape its input.
13+
//
14+
// Each case asserts the SAFE (properly escaped) SQL: the doubled quote is the
15+
// evidence that the breakout attempt was neutralized.
16+
17+
func buildFromQuery(t *testing.T, query string) (string, []any) {
18+
t.Helper()
19+
u, err := url.Parse(query)
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
parts, err := PostgRestParser{}.parse("table", u.Query())
24+
if err != nil {
25+
t.Fatalf("parse error for %q: %v", query, err)
26+
}
27+
sql, values, err := DirectQueryBuilder{}.BuildSelect("table", parts, &QueryOptions{}, nil)
28+
if err != nil {
29+
t.Fatalf("build error for %q: %v", query, err)
30+
}
31+
return sql, values
32+
}
33+
34+
// Finding 4: the select alias (AS "...") was built with raw quotes, so a "
35+
// smuggled into the label broke out of the identifier into the SELECT list.
36+
func TestInjectionSelectAliasQuoting(t *testing.T) {
37+
// label token becomes: x" (the \" is an escaped literal double-quote)
38+
sql, _ := buildFromQuery(t, `?select=x\":a`)
39+
want := `SELECT "table"."a" AS "x""" FROM "table"`
40+
if sql != want {
41+
t.Errorf("alias not escaped\n want: %s\n got: %s", want, sql)
42+
}
43+
}
44+
45+
// Finding 3: JSON-path members were wrapped in bare single quotes, so a '
46+
// smuggled into a path member broke out of the string literal.
47+
func TestInjectionJSONPathQuoting(t *testing.T) {
48+
// path member token becomes: x'y
49+
sql, _ := buildFromQuery(t, `?select=data->>x\'y`)
50+
want := `SELECT ("table"."data"->>'x''y') AS "x'y" FROM "table"`
51+
if sql != want {
52+
t.Errorf("json path member not escaped\n want: %s\n got: %s", want, sql)
53+
}
54+
}
55+
56+
// Finding 2: full-text-search config arguments were interpolated with bare
57+
// single quotes, so a ' in the fts(config) argument broke out of the literal.
58+
func TestInjectionFTSConfigQuoting(t *testing.T) {
59+
// fts config token becomes: en'x
60+
sql, values := buildFromQuery(t, `?body=fts(en\'x).cat`)
61+
want := `SELECT * FROM "table" WHERE "table"."body" @@ to_tsquery('en''x', $1)`
62+
if sql != want {
63+
t.Errorf("fts config not escaped\n want: %s\n got: %s", want, sql)
64+
}
65+
if len(values) != 1 || values[0] != "cat" {
66+
t.Errorf("fts value should be parameterized, got %v", values)
67+
}
68+
}
69+
70+
// SET ROLE takes the role verbatim from the JWT claim. It is built by
71+
// concatenation (PrepareConnection needs a live connection, so this locks in the
72+
// quoting contract that line relies on): a malicious role must stay a single
73+
// quoted identifier and cannot break out into additional statements.
74+
func TestInjectionSetRoleQuoting(t *testing.T) {
75+
cases := map[string]string{
76+
`postgres; DROP TABLE users; --`: `SET ROLE "postgres; DROP TABLE users; --"`,
77+
`admin" ; DROP`: `SET ROLE "admin"" ; DROP"`,
78+
}
79+
for role, want := range cases {
80+
got := "SET ROLE " + quote(role)
81+
if got != want {
82+
t.Errorf("role %q not neutralized\n want: %s\n got: %s", role, want, got)
83+
}
84+
}
85+
}
86+
87+
// Finding 1: embedded-resource filter values take the nmarker == -1 branch of
88+
// appendValue, which interpolated the value with bare single quotes instead of
89+
// using a $N placeholder. This exercises that branch directly.
90+
func TestInjectionEmbeddedValueEscaping(t *testing.T) {
91+
where, _, _ := appendValue("", `x' OR '1'='1`, nil, -1, false)
92+
want := `'x'' OR ''1''=''1'`
93+
if where != want {
94+
t.Errorf("interpolated value not escaped\n want: %s\n got: %s", want, where)
95+
}
96+
}

database/querybuilder.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func prepareField(table, schema string, sfield SelectField, info *SchemaInfo) st
113113
}
114114

115115
if sfield.label != "" {
116-
fieldPart += " AS \"" + sfield.label + "\""
116+
fieldPart += " AS " + quote(sfield.label)
117117
}
118118
return fieldPart
119119
}
@@ -578,7 +578,7 @@ func appendValue(where, value string, valueList []any, nmarker int, forceParam b
578578
where += "$" + strconv.Itoa(nmarker)
579579
valueList = append(valueList, value)
580580
} else {
581-
where += "'" + value + "'"
581+
where += quoteLit(value)
582582
}
583583
return where, valueList, nmarker
584584
}
@@ -687,7 +687,7 @@ func whereClause(table, schema, label string, node *WhereConditionNode, nmarker
687687
if ct != nil && ct.Type != "tsvector" {
688688
lang := "'english'"
689689
if len(node.opArgs) > 0 {
690-
lang = "'" + node.opArgs[0] + "'"
690+
lang = quoteLit(node.opArgs[0])
691691
}
692692
where = where[:len(where)-len(fieldname)] + "to_tsvector(" + lang + ", " + fieldname + ")"
693693
}
@@ -715,7 +715,7 @@ func whereClause(table, schema, label string, node *WhereConditionNode, nmarker
715715
where += "websearch_to_tsquery("
716716
}
717717
for _, arg := range node.opArgs {
718-
where += "'" + arg + "'"
718+
where += quoteLit(arg)
719719
where += ", "
720720
}
721721
where, valueList, _ = appendValue(where, node.values[0], valueList, nmarker, false)

database/requestparser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ func (p *PostgRestParser) field(mayHaveTable bool, mayBeEmpty bool) (f Field, er
424424
if _, err = strconv.Atoi(token); err == nil {
425425
f.jsonPath += token
426426
} else {
427-
f.jsonPath += "'" + token + "'"
427+
f.jsonPath += quoteLit(token)
428428
f.last = token
429429
}
430430
token = p.lookAhead()

server/config.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package server
22

33
import (
44
"bufio"
5+
"crypto/rand"
6+
"encoding/hex"
57
"flag"
68
"fmt"
79
"os"
@@ -105,6 +107,12 @@ func getEnvironment(c *Config) {
105107
c.Logging.Level = "trace"
106108
c.Logging.StdOut = true
107109
c.EnableDebugRoute = true
110+
// Debug mode enables auth (LoginMode: db) but shouldn't force the operator
111+
// to set a secret by hand. Never fall back to the empty (forgeable) key:
112+
// generate a strong random secret for this run.
113+
if c.JWTSecret == "" {
114+
c.JWTSecret = randomSecret()
115+
}
108116
}
109117
allowAnon := os.Getenv("SMOOTHDB_ALLOW_ANON")
110118
if allowAnon != "" {
@@ -228,13 +236,24 @@ func getConfig(baseConfig map[string]any, configOpts *ConfigOptions) (*Config, e
228236
return cfg, nil
229237
}
230238

239+
// randomSecret returns a 32-byte cryptographically-random secret, hex-encoded.
240+
func randomSecret() string {
241+
b := make([]byte, 32)
242+
if _, err := rand.Read(b); err != nil {
243+
// crypto/rand should never fail; if it does, refuse to produce a weak key.
244+
panic("cannot generate a random JWT secret: " + err.Error())
245+
}
246+
return hex.EncodeToString(b)
247+
}
248+
231249
func checkConfig(cfg *Config) error {
232250
if cfg.ShortAPIURL && len(cfg.Database.AllowedDatabases) != 1 {
233251
fmt.Println("Warning: 'ShortAPIURL' requires a single db in 'Database.AllowedDatabases'")
234252
cfg.ShortAPIURL = false
235253
}
236254
if cfg.LoginMode != "none" && cfg.JWTSecret == "" {
237-
fmt.Println("Warning: 'JWTSecret' is empty while authentication is enabled (LoginMode:", cfg.LoginMode+")")
255+
return fmt.Errorf("'JWTSecret' is empty while authentication is enabled (LoginMode: %s): "+
256+
"an empty secret lets anyone forge tokens for any role; set 'JWTSecret' or SMOOTHDB_JWT_SECRET", cfg.LoginMode)
238257
}
239258
if cfg.CORSAllowCredentials {
240259
for _, origin := range cfg.CORSAllowedOrigins {

server/config_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package server
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// An empty JWT secret with authentication enabled means tokens are signed/verified
9+
// with an empty HMAC key — anyone can forge a token for any role. The server must
10+
// refuse to start rather than only warn.
11+
func TestCheckConfigRejectsEmptyJWTSecret(t *testing.T) {
12+
cfg := defaultConfig()
13+
cfg.LoginMode = "jwt"
14+
cfg.JWTSecret = ""
15+
16+
err := checkConfig(cfg)
17+
if err == nil {
18+
t.Fatal("expected checkConfig to reject an empty JWTSecret with auth enabled")
19+
}
20+
if !strings.Contains(err.Error(), "JWTSecret") {
21+
t.Errorf("expected error to mention JWTSecret, got: %v", err)
22+
}
23+
}
24+
25+
// Debug mode is a dev convenience and should not require the operator to set a
26+
// secret by hand — but it must not fall back to the empty (forgeable) key. It
27+
// should auto-generate a strong random secret.
28+
func TestDebugModeGeneratesJWTSecret(t *testing.T) {
29+
t.Setenv("SMOOTHDB_DEBUG", "true")
30+
t.Setenv("SMOOTHDB_JWT_SECRET", "")
31+
32+
cfg := defaultConfig()
33+
cfg.JWTSecret = ""
34+
getEnvironment(cfg)
35+
36+
if cfg.JWTSecret == "" {
37+
t.Fatal("debug mode should auto-generate a JWTSecret instead of leaving it empty")
38+
}
39+
if len(cfg.JWTSecret) < 32 {
40+
t.Errorf("auto-generated secret too short (%d chars); want a strong random value", len(cfg.JWTSecret))
41+
}
42+
}

0 commit comments

Comments
 (0)