Skip to content

Commit 943828f

Browse files
authored
fix(auth-guard): lazy provider + enforce secret minimum length at startup (#439)
* fix(examples): defer auth-guard session setup out of the init hook The generated app registers the auth provider from its init() hook, before the env-contract validation runs. The hook called authguard.MustAuthProvider(), which constructed Sessions eagerly and panicked when GOWDK_AUTH_SESSION_SECRET was unset or shorter than the required 32 bytes — so a secret misconfiguration crashed the binary with a stack trace at startup instead of returning the generated env-contract error. Replace MustAuthProvider with a lazy AuthProvider that resolves Sessions inside Principal on the first request, and point the generated hook at it. A missing or short secret now surfaces as the clean "GOWDK_AUTH_SESSION_SECRET is required" startup error (verified by running the built binary with the secret unset). * fix(env): enforce SecretEnv minimum length at build and startup Follow-up to the lazy auth provider: deferring Sessions() construction meant a present-but-too-short GOWDK_AUTH_SESSION_SECRET no longer failed at init, and the env contract only checked for empty secrets — so a weak signing key let the binary start and only failed the first guarded request. Add SecretEnv.MinBytes and enforce it in both the build-time env validation (gowdk.EnvConfig.Validate) and the generated runtime contract (internal/appgen validateEnvContract), parse it from executable and AST configs (internal/project), register the new short_secret diagnostic, and set MinBytes: 32 on the auth-guard example's session and CSRF secrets. A short secret now fails fast with "GOWDK_AUTH_SESSION_SECRET must be at least 32 bytes" at startup (verified by running the built binary). Tests added for both the build-time validation and the generated contract. * fix(config): bounds-check int64->int narrowing for SecretEnv.MinBytes CodeQL flagged the unchecked int64->int conversion when parsing SecretEnv.MinBytes from the AST config path. Route it through a new parseInt helper that clamps out-of-range values to the platform int range so a malformed config value cannot wrap into a small or negative length on 32-bit platforms. Add a LoadConfigFile regression test that enforces MinBytes end-to-end through the AST path.
1 parent d9d4b35 commit 943828f

10 files changed

Lines changed: 180 additions & 15 deletions

File tree

examples/auth-guard/apphooks/auth_guard_hooks.go.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,8 @@ func GOWDKGuardRegistry() gowdkguard.Registry {
1313
}
1414

1515
func GOWDKAuthProvider() gowdkauth.Provider {
16-
return authguard.MustAuthProvider()
16+
// Lazy provider: never construct Sessions at init time, so a missing or
17+
// too-short GOWDK_AUTH_SESSION_SECRET surfaces as the generated env-contract
18+
// startup error rather than an init-time panic.
19+
return authguard.AuthProvider()
1720
}

examples/auth-guard/gowdk.config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ var Config = gowdk.Config{
1313
},
1414
Env: gowdk.EnvConfig{
1515
Secrets: []gowdk.SecretEnv{
16-
{Name: "GOWDK_AUTH_SESSION_SECRET", Required: true},
17-
{Name: "GOWDK_CSRF_SECRET", Required: true},
16+
{Name: "GOWDK_AUTH_SESSION_SECRET", Required: true, MinBytes: 32},
17+
{Name: "GOWDK_CSRF_SECRET", Required: true, MinBytes: 32},
1818
},
1919
},
2020
Build: gowdk.BuildConfig{

examples/auth-guard/src/authguard/auth.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto/subtle"
66
"fmt"
7+
"net/http"
78
"os"
89
"strings"
910
"sync"
@@ -113,12 +114,21 @@ func RequireSession(ctx guard.Context) error {
113114
return nil
114115
}
115116

116-
func MustAuthProvider() gowdkauth.Provider {
117-
sessions, err := Sessions()
118-
if err != nil {
119-
panic(err)
120-
}
121-
return sessions.Provider()
117+
// AuthProvider returns a Provider that resolves the session manager lazily on
118+
// the first request. The generated app registers the provider from its init()
119+
// hook, which runs before the app's env-contract validation. Building Sessions
120+
// here (as the previous MustAuthProvider did) would panic on a missing or
121+
// too-short GOWDK_AUTH_SESSION_SECRET and crash with a stack trace instead of
122+
// the clean startup error. Deferring construction to Principal keeps secret
123+
// misconfiguration on the normal validation path.
124+
func AuthProvider() gowdkauth.Provider {
125+
return gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) {
126+
sessions, err := Sessions()
127+
if err != nil {
128+
return nil, err
129+
}
130+
return sessions.Principal(request)
131+
})
122132
}
123133

124134
func Sessions() (*gowdkauth.Sessions, error) {

gowdk.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ type EnvVar struct {
6767
type SecretEnv struct {
6868
Name string
6969
Required bool
70+
// MinBytes rejects a present-but-too-short secret at build time and at
71+
// generated-app startup. Zero means no minimum. This lets the env contract
72+
// fail fast on a weak signing key instead of deferring the failure to the
73+
// first request that constructs the signer.
74+
MinBytes int
7075
}
7176

7277
// EnvValidationError describes one invalid or missing env contract entry.
@@ -123,9 +128,14 @@ func (config EnvConfig) Validate(lookup func(string) (string, bool)) error {
123128
continue
124129
}
125130
diagnostics = append(diagnostics, validateEnvDuplicate(seen, name, "Secrets")...)
126-
if lookup != nil && secret.Required {
127-
if value, ok := lookup(name); !ok || strings.TrimSpace(value) == "" {
131+
if lookup != nil {
132+
value, ok := lookup(name)
133+
trimmed := strings.TrimSpace(value)
134+
switch {
135+
case secret.Required && (!ok || trimmed == ""):
128136
diagnostics = append(diagnostics, EnvValidationError{Code: "missing_required_secret", Name: name, Message: fmt.Sprintf("%s is required but is not set", name)})
137+
case secret.MinBytes > 0 && trimmed != "" && len(trimmed) < secret.MinBytes:
138+
diagnostics = append(diagnostics, EnvValidationError{Code: "short_secret", Name: name, Message: fmt.Sprintf("%s must be at least %d bytes", name, secret.MinBytes)})
129139
}
130140
}
131141
}

internal/appgen/appgen_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,39 @@ func TestGenerateWiresEnvContractValidation(t *testing.T) {
13861386
}
13871387
}
13881388

1389+
func TestGenerateEnvContractEnforcesSecretMinBytes(t *testing.T) {
1390+
root := t.TempDir()
1391+
outputDir := filepath.Join(root, "dist")
1392+
appDir := filepath.Join(root, "generated-app")
1393+
writeTestFile(t, filepath.Join(outputDir, "index.html"), "<main>Home</main>")
1394+
1395+
result, err := GenerateWithOptions(outputDir, appDir, Options{
1396+
Config: gowdk.Config{Env: gowdk.EnvConfig{
1397+
Secrets: []gowdk.SecretEnv{
1398+
{Name: "GOWDK_TEST_SESSION_SECRET", Required: true, MinBytes: 32},
1399+
},
1400+
}},
1401+
})
1402+
if err != nil {
1403+
t.Fatal(err)
1404+
}
1405+
payload, err := os.ReadFile(result.PackagePath)
1406+
if err != nil {
1407+
t.Fatal(err)
1408+
}
1409+
source := string(payload)
1410+
for _, expected := range []string{
1411+
`value := strings.TrimSpace(os.Getenv("GOWDK_TEST_SESSION_SECRET"))`,
1412+
`missing = append(missing, "GOWDK_TEST_SESSION_SECRET is required but is not set")`,
1413+
`} else if len(value) < 32 {`,
1414+
`missing = append(missing, "GOWDK_TEST_SESSION_SECRET must be at least 32 bytes")`,
1415+
} {
1416+
if !strings.Contains(source, expected) {
1417+
t.Fatalf("expected generated env contract to enforce the secret minimum %q:\n%s", expected, source)
1418+
}
1419+
}
1420+
}
1421+
13891422
func TestGenerateRunsRateLimitAndGuardsBeforeContractExecution(t *testing.T) {
13901423
root := t.TempDir()
13911424
outputDir := filepath.Join(root, "dist")

internal/appgen/source_env.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package appgen
22

33
import (
4+
"fmt"
45
"go/ast"
56
"go/token"
67

@@ -14,7 +15,7 @@ func envRuntimeValidationRequired(config gowdk.EnvConfig) bool {
1415
}
1516
}
1617
for _, secret := range config.Secrets {
17-
if secret.Required {
18+
if secret.Required || secret.MinBytes > 0 {
1819
return true
1920
}
2021
}
@@ -35,10 +36,9 @@ func validateEnvContractDecl(config gowdk.EnvConfig) []ast.Decl {
3536
stmts = append(stmts, appendMissingEnvStmt(variable.Name))
3637
}
3738
for _, secret := range config.Secrets {
38-
if !secret.Required {
39-
continue
39+
if stmt := appendSecretEnvStmt(secret); stmt != nil {
40+
stmts = append(stmts, stmt)
4041
}
41-
stmts = append(stmts, appendMissingEnvStmt(secret.Name))
4242
}
4343
stmts = append(stmts,
4444
&ast.IfStmt{
@@ -64,6 +64,43 @@ func appendMissingEnvStmt(name string) ast.Stmt {
6464
}
6565
}
6666

67+
// appendSecretEnvStmt validates one secret env var. A required secret with no
68+
// minimum keeps the existing empty-only check. A secret with MinBytes also
69+
// rejects a present-but-too-short value, so a weak signing key fails the env
70+
// contract at startup instead of deferring to the first request.
71+
func appendSecretEnvStmt(secret gowdk.SecretEnv) ast.Stmt {
72+
if secret.MinBytes <= 0 {
73+
if secret.Required {
74+
return appendMissingEnvStmt(secret.Name)
75+
}
76+
return nil
77+
}
78+
appendMissing := func(message string) ast.Stmt {
79+
return assign([]ast.Expr{id("missing")}, call(id("append"), id("missing"), stringLit(message)))
80+
}
81+
getenv := define([]ast.Expr{id("value")}, call(sel("strings", "TrimSpace"), call(sel("os", "Getenv"), stringLit(secret.Name))))
82+
tooShort := &ast.BinaryExpr{X: call(id("len"), id("value")), Op: token.LSS, Y: intLit(secret.MinBytes)}
83+
shortStmt := block(appendMissing(fmt.Sprintf("%s must be at least %d bytes", secret.Name, secret.MinBytes)))
84+
if !secret.Required {
85+
// Optional secret: only enforce the minimum when a value is present.
86+
return &ast.IfStmt{
87+
Init: getenv,
88+
Cond: &ast.BinaryExpr{
89+
X: &ast.BinaryExpr{X: id("value"), Op: token.NEQ, Y: stringLit("")},
90+
Op: token.LAND,
91+
Y: tooShort,
92+
},
93+
Body: shortStmt,
94+
}
95+
}
96+
return &ast.IfStmt{
97+
Init: getenv,
98+
Cond: &ast.BinaryExpr{X: id("value"), Op: token.EQL, Y: stringLit("")},
99+
Body: block(appendMissing(secret.Name + " is required but is not set")),
100+
Else: &ast.IfStmt{Cond: tooShort, Body: shortStmt},
101+
}
102+
}
103+
67104
func validateEnvContractStmt(config gowdk.EnvConfig) []ast.Stmt {
68105
if !envRuntimeValidationRequired(config) {
69106
return nil

internal/diagnostics/registry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ var Registry = []Code{
159159
{Code: "route_method_conflict", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "two generated handlers claim the same method and route pattern"},
160160
{Code: "secret_env_in_vars", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "secret-looking environment variable is declared as a normal variable"},
161161
{Code: "secret_env_name_required", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "secret environment contract entry is missing a name"},
162+
{Code: "short_secret", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "secret environment variable is shorter than its required minimum length"},
162163
{Code: "spa_disabled", Area: "routing", Stability: StabilityExperimental, Severity: SeverityInfo, Summary: "SPA route binding is disabled for this generated route lane"},
163164
{Code: "spa_dynamic_route_missing_paths", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "dynamic SPA route is missing paths declarations"},
164165
{Code: "ssr_disabled", Area: "routing", Stability: StabilityExperimental, Severity: SeverityInfo, Summary: "SSR route binding is disabled for this generated route lane"},

internal/project/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"go/ast"
77
"go/parser"
88
"go/token"
9+
"math"
910
"os"
1011
"path"
1112
"strconv"
@@ -467,6 +468,8 @@ func parseSecretEnv(expression ast.Expr) (gowdk.SecretEnv, bool, error) {
467468
secret.Name = parseString(keyValue.Value)
468469
case "Required":
469470
secret.Required = parseBool(keyValue.Value)
471+
case "MinBytes":
472+
secret.MinBytes = parseInt(keyValue.Value)
470473
case "Default", "Value":
471474
return gowdk.SecretEnv{}, false, fmt.Errorf("Env.Secrets entries cannot declare %s; secret values must come from the runtime environment", key.Name)
472475
}
@@ -1050,6 +1053,21 @@ func parseBool(expression ast.Expr) bool {
10501053
return ok && identifier.Name == "true"
10511054
}
10521055

1056+
// parseInt narrows a parsed integer literal to int, clamping values that fall
1057+
// outside the platform int range so a malformed config value cannot wrap into a
1058+
// small or negative length on 32-bit platforms.
1059+
func parseInt(expression ast.Expr) int {
1060+
value := parseInt64(expression)
1061+
switch {
1062+
case value > math.MaxInt:
1063+
return math.MaxInt
1064+
case value < math.MinInt:
1065+
return math.MinInt
1066+
default:
1067+
return int(value)
1068+
}
1069+
}
1070+
10531071
func parseInt64(expression ast.Expr) int64 {
10541072
switch typed := expression.(type) {
10551073
case *ast.BasicLit:

internal/project/config_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,35 @@ var Config = gowdk.Config{
268268
}
269269
}
270270

271+
func TestLoadConfigFileEnforcesSecretMinBytes(t *testing.T) {
272+
const name = "GOWDK_TEST_SESSION_SECRET"
273+
t.Setenv(name, "too-short")
274+
275+
root := t.TempDir()
276+
path := filepath.Join(root, DefaultConfigFile)
277+
if err := os.WriteFile(path, []byte(`package app
278+
279+
import "github.com/cssbruno/gowdk"
280+
281+
var Config = gowdk.Config{
282+
Env: gowdk.EnvConfig{
283+
Secrets: []gowdk.SecretEnv{
284+
{Name: "GOWDK_TEST_SESSION_SECRET", Required: true, MinBytes: 32},
285+
},
286+
},
287+
}
288+
`), 0o644); err != nil {
289+
t.Fatal(err)
290+
}
291+
292+
// The AST config path must carry MinBytes through to validation; without it
293+
// a non-empty short secret would pass the contract and only fail at runtime.
294+
_, err := LoadConfigFile(path)
295+
if err == nil || !strings.Contains(err.Error(), "GOWDK_TEST_SESSION_SECRET must be at least 32 bytes") {
296+
t.Fatalf("expected short-secret validation error, got %v", err)
297+
}
298+
}
299+
271300
func TestLoadConfigFileRejectsSecretEnvMisuse(t *testing.T) {
272301
root := t.TempDir()
273302
path := filepath.Join(root, DefaultConfigFile)

internal/publicapi/gowdk_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,30 @@ func TestEnvConfigValidateReportsMissingRequiredNames(t *testing.T) {
100100
}
101101
}
102102

103+
func TestEnvConfigValidateRejectsShortSecret(t *testing.T) {
104+
config := gowdk.EnvConfig{
105+
Secrets: []gowdk.SecretEnv{
106+
{Name: "GOWDK_TEST_SESSION_SECRET", Required: true, MinBytes: 32},
107+
},
108+
}
109+
110+
short := config.Validate(func(string) (string, bool) { return "too-short", true })
111+
if short == nil || !strings.Contains(short.Error(), "GOWDK_TEST_SESSION_SECRET must be at least 32 bytes") {
112+
t.Fatalf("expected short-secret error, got %v", short)
113+
}
114+
115+
missing := config.Validate(func(string) (string, bool) { return "", true })
116+
if missing == nil || !strings.Contains(missing.Error(), "GOWDK_TEST_SESSION_SECRET is required but is not set") {
117+
t.Fatalf("expected missing-secret error, got %v", missing)
118+
}
119+
120+
if err := config.Validate(func(string) (string, bool) {
121+
return "0123456789ABCDEF0123456789ABCDEF", true
122+
}); err != nil {
123+
t.Fatalf("expected a 32-byte secret to pass, got %v", err)
124+
}
125+
}
126+
103127
func TestBuildConfigDebugAssets(t *testing.T) {
104128
if !(gowdk.BuildConfig{}).DebugAssets() {
105129
t.Fatal("expected omitted build mode to include debug assets")

0 commit comments

Comments
 (0)