Skip to content

Commit 62a3ef5

Browse files
committed
security: webhook empty-secret + JWT ephemeral-key footguns (T0-3)
2 parents 6575b86 + 167d336 commit 62a3ef5

4 files changed

Lines changed: 128 additions & 87 deletions

File tree

internal/adapter/remnawave/webhook_handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6868
// verifySignature computes HMAC-SHA256 of body using the shared secret and
6969
// compares it to the provided hex-encoded signature in constant time.
7070
func (h *WebhookHandler) verifySignature(body []byte, sigHex string) bool {
71+
// Fail closed on an unset secret: HMAC over an empty key is trivially
72+
// forgeable, so an attacker could inject arbitrary Remnawave payloads. Reject
73+
// every webhook until a real secret is configured.
74+
if h.secret == "" {
75+
return false
76+
}
77+
7178
sig, err := hex.DecodeString(sigHex)
7279
if err != nil {
7380
return false

internal/adapter/remnawave/webhook_handler_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,23 @@ func TestWebhookHandler_EmptyBody(t *testing.T) {
7878
func TestHeaderWebhookSecret_MatchesPanel(t *testing.T) {
7979
assert.Equal(t, "X-Remnawave-Signature", HeaderWebhookSecret)
8080
}
81+
82+
// TestWebhookHandler_EmptySecretRejectsAll verifies fail-closed behavior: with
83+
// no configured secret, even a signature correctly computed over the empty key
84+
// is rejected (otherwise webhooks would be forgeable).
85+
func TestWebhookHandler_EmptySecretRejectsAll(t *testing.T) {
86+
called := false
87+
handler := NewWebhookHandler("", func(WebhookPayload) { called = true })
88+
89+
body := `{"scope":"user","event":"created"}`
90+
// Signature an attacker would compute against the empty key.
91+
sig := computeHMAC(body, "")
92+
93+
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/remnawave", strings.NewReader(body))
94+
req.Header.Set(HeaderWebhookSecret, sig)
95+
rec := httptest.NewRecorder()
96+
handler.ServeHTTP(rec, req)
97+
98+
assert.Equal(t, http.StatusForbidden, rec.Code)
99+
assert.False(t, called, "callback must not fire when the secret is unset")
100+
}

internal/app/wiring_identity.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,15 @@ func provideJWTIssuer(cfg *config.Config, logger *slog.Logger) (*authutil.JWTIss
128128
return nil, fmt.Errorf("loading private key from %s: %w", cfg.JWT.PrivateKeyPath, err)
129129
}
130130

131-
// File does not exist — generate an ephemeral key for development.
131+
// File does not exist. Fail closed unless ephemeral keys are explicitly
132+
// allowed (dev only): silently synthesizing a per-process signing key means
133+
// tokens die on every restart and replicas reject each other's tokens — an
134+
// auth footgun that must never happen by accident in production.
135+
if !cfg.JWT.AllowEphemeralKey {
136+
return nil, fmt.Errorf("jwt private key not found at %s and JWT_ALLOW_EPHEMERAL_KEY is not set: refusing to boot with a throwaway signing key (set the key file, or enable ephemeral keys for local dev only)", cfg.JWT.PrivateKeyPath)
137+
}
138+
139+
// Ephemeral key for development.
132140
logger.Warn("jwt private key file not found, generating ephemeral P-256 key (NOT FOR PRODUCTION)",
133141
slog.String("path", cfg.JWT.PrivateKeyPath),
134142
)

internal/config/config.go

Lines changed: 92 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,37 @@ import (
1212
)
1313

1414
const (
15-
DefaultAppPort = 4000
16-
DefaultLogLevel = "debug"
17-
DefaultLogFormat = "json"
18-
DefaultPoolMaxConns int32 = 20
19-
DefaultPoolMinConns int32 = 5
20-
DefaultPoolMaxConnLifetime = 1 * time.Hour
21-
DefaultPoolMaxConnIdleTime = 30 * time.Minute
22-
DefaultPoolHealthCheck = 1 * time.Minute
23-
DefaultJWTAccessTTL = 15 * time.Minute
24-
DefaultJWTRefreshTTL = 7 * 24 * time.Hour // 1 week
25-
DefaultBillingTrialDays = 7
26-
DefaultPluginsDir = "./plugins"
27-
DefaultMaxPlugins = 50
28-
DefaultPluginHotReload = false
29-
DefaultHealthCheckInterval = 10 // seconds
30-
DefaultMaxConcurrentChecks = 50
31-
DefaultSpeedTestPort = 4203
32-
DefaultSubscriptionProxyPort = 4100
33-
DefaultCheckoutMaxPerHour = 10
34-
DefaultSubscriptionMaxPerDay = 5
35-
DefaultLoginMaxPerWindow = 20
36-
DefaultLoginWindowMinutes = 15
37-
DefaultForgotPwdMaxPerWindow = 3
38-
DefaultForgotPwdWindowMinutes = 60
39-
DefaultAcceptInvitationMaxPerWindow = 3
40-
DefaultAcceptInvitationWindowMinutes = 60
41-
DefaultOutboxRelayWorkers = 1
42-
DefaultOutboxPartitionLookahead = 2
43-
DefaultOutboxRetentionDays = 90
44-
DefaultHooksSubscriptionEnabled = false
45-
DefaultHooksVPNProviderEnabled = false
15+
DefaultAppPort = 4000
16+
DefaultLogLevel = "debug"
17+
DefaultLogFormat = "json"
18+
DefaultPoolMaxConns int32 = 20
19+
DefaultPoolMinConns int32 = 5
20+
DefaultPoolMaxConnLifetime = 1 * time.Hour
21+
DefaultPoolMaxConnIdleTime = 30 * time.Minute
22+
DefaultPoolHealthCheck = 1 * time.Minute
23+
DefaultJWTAccessTTL = 15 * time.Minute
24+
DefaultJWTRefreshTTL = 7 * 24 * time.Hour // 1 week
25+
DefaultBillingTrialDays = 7
26+
DefaultPluginsDir = "./plugins"
27+
DefaultMaxPlugins = 50
28+
DefaultPluginHotReload = false
29+
DefaultHealthCheckInterval = 10 // seconds
30+
DefaultMaxConcurrentChecks = 50
31+
DefaultSpeedTestPort = 4203
32+
DefaultSubscriptionProxyPort = 4100
33+
DefaultCheckoutMaxPerHour = 10
34+
DefaultSubscriptionMaxPerDay = 5
35+
DefaultLoginMaxPerWindow = 20
36+
DefaultLoginWindowMinutes = 15
37+
DefaultForgotPwdMaxPerWindow = 3
38+
DefaultForgotPwdWindowMinutes = 60
39+
DefaultAcceptInvitationMaxPerWindow = 3
40+
DefaultAcceptInvitationWindowMinutes = 60
41+
DefaultOutboxRelayWorkers = 1
42+
DefaultOutboxPartitionLookahead = 2
43+
DefaultOutboxRetentionDays = 90
44+
DefaultHooksSubscriptionEnabled = false
45+
DefaultHooksVPNProviderEnabled = false
4646

4747
// Smart router default weights — browsing.
4848
DefaultWeightGeo = 0.33
@@ -97,6 +97,12 @@ type JWTConfig struct {
9797
PublicKeyPath string `koanf:"public_key_path"`
9898
AccessTokenTTL time.Duration `koanf:"access_token_ttl"`
9999
RefreshTokenTTL time.Duration `koanf:"refresh_token_ttl"`
100+
// AllowEphemeralKey permits generating a throwaway in-memory signing key when
101+
// the key file is missing. DEV ONLY: an ephemeral key invalidates all tokens
102+
// on restart and makes multi-replica deployments reject each other's tokens,
103+
// so production must ship a real key file. Defaults to false (boot fails if
104+
// the key is absent).
105+
AllowEphemeralKey bool `koanf:"allow_ephemeral_key"`
100106
}
101107

102108
type RemnawaveConfig struct {
@@ -167,12 +173,12 @@ type TracingConfig struct {
167173

168174
// RateLimitConfig holds domain-level rate limit thresholds.
169175
type RateLimitConfig struct {
170-
CheckoutMaxPerHour int `koanf:"checkout_max_per_hour"`
171-
SubscriptionMaxPerDay int `koanf:"subscription_max_per_day"`
172-
LoginMaxPerWindow int `koanf:"login_max_per_window"`
173-
LoginWindowMinutes int `koanf:"login_window_minutes"`
174-
ForgotPwdMaxPerWindow int `koanf:"forgot_pwd_max_per_window"`
175-
ForgotPwdWindowMinutes int `koanf:"forgot_pwd_window_minutes"`
176+
CheckoutMaxPerHour int `koanf:"checkout_max_per_hour"`
177+
SubscriptionMaxPerDay int `koanf:"subscription_max_per_day"`
178+
LoginMaxPerWindow int `koanf:"login_max_per_window"`
179+
LoginWindowMinutes int `koanf:"login_window_minutes"`
180+
ForgotPwdMaxPerWindow int `koanf:"forgot_pwd_max_per_window"`
181+
ForgotPwdWindowMinutes int `koanf:"forgot_pwd_window_minutes"`
176182
AcceptInvitationMaxPerWindow int `koanf:"accept_invitation_max_per_window"`
177183
AcceptInvitationWindowMinutes int `koanf:"accept_invitation_window_minutes"`
178184
}
@@ -255,38 +261,38 @@ func Load() (*Config, error) {
255261

256262
// Set defaults
257263
defaults := map[string]any{
258-
"app.port": DefaultAppPort,
259-
"app.version": DefaultAppVersion,
260-
"app.log_level": DefaultLogLevel,
261-
"app.log_format": DefaultLogFormat,
262-
"database.max_conns": DefaultPoolMaxConns,
263-
"database.min_conns": DefaultPoolMinConns,
264-
"database.max_conn_lifetime": DefaultPoolMaxConnLifetime,
265-
"database.max_conn_idle_time": DefaultPoolMaxConnIdleTime,
266-
"database.health_check_period": DefaultPoolHealthCheck,
267-
"jwt.access_token_ttl": DefaultJWTAccessTTL,
268-
"jwt.refresh_token_ttl": DefaultJWTRefreshTTL,
269-
"billing.trial_days": DefaultBillingTrialDays,
270-
"plugin.dir": DefaultPluginsDir,
271-
"plugin.max_plugins": DefaultMaxPlugins,
272-
"plugin.hot_reload": DefaultPluginHotReload,
273-
"infra.health_check_interval": time.Duration(DefaultHealthCheckInterval) * time.Second,
274-
"infra.max_concurrent_checks": DefaultMaxConcurrentChecks,
275-
"infra.speed_test_port": DefaultSpeedTestPort,
276-
"infra.subscription_proxy_port": DefaultSubscriptionProxyPort,
277-
"ratelimit.checkout_max_per_hour": DefaultCheckoutMaxPerHour,
278-
"ratelimit.subscription_max_per_day": DefaultSubscriptionMaxPerDay,
279-
"ratelimit.login_max_per_window": DefaultLoginMaxPerWindow,
280-
"ratelimit.login_window_minutes": DefaultLoginWindowMinutes,
281-
"ratelimit.forgot_pwd_max_per_window": DefaultForgotPwdMaxPerWindow,
282-
"ratelimit.forgot_pwd_window_minutes": DefaultForgotPwdWindowMinutes,
283-
"ratelimit.accept_invitation_max_per_window": DefaultAcceptInvitationMaxPerWindow,
284-
"ratelimit.accept_invitation_window_minutes": DefaultAcceptInvitationWindowMinutes,
285-
"outbox.relay_workers": DefaultOutboxRelayWorkers,
286-
"outbox.partition_lookahead": DefaultOutboxPartitionLookahead,
287-
"outbox.retention_days": DefaultOutboxRetentionDays,
288-
"featureflags.hooks_subscription_enabled": DefaultHooksSubscriptionEnabled,
289-
"featureflags.hooks_vpn_provider_enabled": DefaultHooksVPNProviderEnabled,
264+
"app.port": DefaultAppPort,
265+
"app.version": DefaultAppVersion,
266+
"app.log_level": DefaultLogLevel,
267+
"app.log_format": DefaultLogFormat,
268+
"database.max_conns": DefaultPoolMaxConns,
269+
"database.min_conns": DefaultPoolMinConns,
270+
"database.max_conn_lifetime": DefaultPoolMaxConnLifetime,
271+
"database.max_conn_idle_time": DefaultPoolMaxConnIdleTime,
272+
"database.health_check_period": DefaultPoolHealthCheck,
273+
"jwt.access_token_ttl": DefaultJWTAccessTTL,
274+
"jwt.refresh_token_ttl": DefaultJWTRefreshTTL,
275+
"billing.trial_days": DefaultBillingTrialDays,
276+
"plugin.dir": DefaultPluginsDir,
277+
"plugin.max_plugins": DefaultMaxPlugins,
278+
"plugin.hot_reload": DefaultPluginHotReload,
279+
"infra.health_check_interval": time.Duration(DefaultHealthCheckInterval) * time.Second,
280+
"infra.max_concurrent_checks": DefaultMaxConcurrentChecks,
281+
"infra.speed_test_port": DefaultSpeedTestPort,
282+
"infra.subscription_proxy_port": DefaultSubscriptionProxyPort,
283+
"ratelimit.checkout_max_per_hour": DefaultCheckoutMaxPerHour,
284+
"ratelimit.subscription_max_per_day": DefaultSubscriptionMaxPerDay,
285+
"ratelimit.login_max_per_window": DefaultLoginMaxPerWindow,
286+
"ratelimit.login_window_minutes": DefaultLoginWindowMinutes,
287+
"ratelimit.forgot_pwd_max_per_window": DefaultForgotPwdMaxPerWindow,
288+
"ratelimit.forgot_pwd_window_minutes": DefaultForgotPwdWindowMinutes,
289+
"ratelimit.accept_invitation_max_per_window": DefaultAcceptInvitationMaxPerWindow,
290+
"ratelimit.accept_invitation_window_minutes": DefaultAcceptInvitationWindowMinutes,
291+
"outbox.relay_workers": DefaultOutboxRelayWorkers,
292+
"outbox.partition_lookahead": DefaultOutboxPartitionLookahead,
293+
"outbox.retention_days": DefaultOutboxRetentionDays,
294+
"featureflags.hooks_subscription_enabled": DefaultHooksSubscriptionEnabled,
295+
"featureflags.hooks_vpn_provider_enabled": DefaultHooksVPNProviderEnabled,
290296
// Smart router weight defaults.
291297
"smartrouter.weight_geo": DefaultWeightGeo,
292298
"smartrouter.weight_latency": DefaultWeightLatency,
@@ -298,27 +304,27 @@ func Load() (*Config, error) {
298304
"smartrouter.weight_streaming_latency": DefaultWeightStreamingLatency,
299305
"smartrouter.weight_streaming_load": DefaultWeightStreamingLoad,
300306
// Speed test defaults.
301-
"speedtest.max_concurrent": DefaultSpeedTestMaxConcurrent,
307+
"speedtest.max_concurrent": DefaultSpeedTestMaxConcurrent,
302308
"speedtest.per_ip_rate_limit": DefaultSpeedTestPerIPRateLimit,
303-
"speedtest.max_upload_bytes": DefaultSpeedTestMaxUploadBytes,
309+
"speedtest.max_upload_bytes": DefaultSpeedTestMaxUploadBytes,
304310
// Circuit breaker defaults — Remnawave and VPN provider use DefaultConfig
305311
// (with interval), outbox relay and Valkey use DefaultConfigNoInterval.
306312
"circuitbreaker.remnawave.max_failures": circuitbreaker.DefaultMaxFailures,
307-
"circuitbreaker.remnawave.timeout": circuitbreaker.DefaultTimeout,
308-
"circuitbreaker.remnawave.max_requests": circuitbreaker.DefaultMaxRequests,
309-
"circuitbreaker.remnawave.interval": circuitbreaker.DefaultInterval,
310-
"circuitbreaker.outbox_nats.max_failures": circuitbreaker.DefaultMaxFailures,
311-
"circuitbreaker.outbox_nats.timeout": circuitbreaker.DefaultTimeout,
312-
"circuitbreaker.outbox_nats.max_requests": uint32(1),
313-
"circuitbreaker.outbox_nats.interval": time.Duration(0),
314-
"circuitbreaker.valkey.max_failures": circuitbreaker.DefaultMaxFailures,
315-
"circuitbreaker.valkey.timeout": circuitbreaker.DefaultTimeout,
316-
"circuitbreaker.valkey.max_requests": circuitbreaker.DefaultMaxRequests,
317-
"circuitbreaker.valkey.interval": time.Duration(0),
318-
"circuitbreaker.vpn_provider.max_failures": circuitbreaker.DefaultMaxFailures,
319-
"circuitbreaker.vpn_provider.timeout": circuitbreaker.DefaultTimeout,
320-
"circuitbreaker.vpn_provider.max_requests": circuitbreaker.DefaultMaxRequests,
321-
"circuitbreaker.vpn_provider.interval": circuitbreaker.DefaultInterval,
313+
"circuitbreaker.remnawave.timeout": circuitbreaker.DefaultTimeout,
314+
"circuitbreaker.remnawave.max_requests": circuitbreaker.DefaultMaxRequests,
315+
"circuitbreaker.remnawave.interval": circuitbreaker.DefaultInterval,
316+
"circuitbreaker.outbox_nats.max_failures": circuitbreaker.DefaultMaxFailures,
317+
"circuitbreaker.outbox_nats.timeout": circuitbreaker.DefaultTimeout,
318+
"circuitbreaker.outbox_nats.max_requests": uint32(1),
319+
"circuitbreaker.outbox_nats.interval": time.Duration(0),
320+
"circuitbreaker.valkey.max_failures": circuitbreaker.DefaultMaxFailures,
321+
"circuitbreaker.valkey.timeout": circuitbreaker.DefaultTimeout,
322+
"circuitbreaker.valkey.max_requests": circuitbreaker.DefaultMaxRequests,
323+
"circuitbreaker.valkey.interval": time.Duration(0),
324+
"circuitbreaker.vpn_provider.max_failures": circuitbreaker.DefaultMaxFailures,
325+
"circuitbreaker.vpn_provider.timeout": circuitbreaker.DefaultTimeout,
326+
"circuitbreaker.vpn_provider.max_requests": circuitbreaker.DefaultMaxRequests,
327+
"circuitbreaker.vpn_provider.interval": circuitbreaker.DefaultInterval,
322328
}
323329
for key, val := range defaults {
324330
k.Set(key, val) //nolint:errcheck // Set on a fresh koanf instance cannot fail

0 commit comments

Comments
 (0)