Skip to content

Commit 1bbb31e

Browse files
authored
fix(conf): harden settings validation for security and correctness (#2876)
* fix(conf): harden settings validation for security and correctness Tighten input validation across settings to reject invalid values that were previously accepted silently: - Reject zero and negative realtime intervals, cap at 24h - Strip HTML tags from site name (defense in depth against stored XSS) - Reject path traversal (..) and absolute paths in audio export path - Validate EQ filter frequency > 0 and Q > 0 for both global and per-source equalizers, with upper bounds - Add dynamic threshold cross-field validation: min <= trigger, validHours > 0 when enabled, both in [0,1] range - Default empty BirdNET locale to "en" instead of leaving it blank - Validate weather provider against known values - Reject negative retention maxAge (parsed duration) and negative minClips regardless of retention policy - Fix pre-existing goconst lint issue in test_helpers.go * fix(api): update test settings to satisfy stricter validation The validation hardening requires positive realtime interval, non-empty BirdNET locale, and relative export paths. Update the API v2 test helper to provide valid defaults so existing PATCH tests continue to pass with the stricter checks. * fix(conf): address review feedback on validation hardening - Move MinClips check above empty-policy early return so negative values are rejected regardless of retention policy - Rename misleading test case from "windows absolute rejected" to "windows-style path treated as relative on unix" * fix(conf): add per-stream EQ validation and update test expectations Add EQ filter validation for per-stream equalizer settings (was missing after per-source and global EQ were covered). Update malicious input test to accept 400 responses for path traversal (now correctly rejected at validation time). Update export path assertion in settings update test to match relative path default. * fix(conf): use require.NoError in validation tests and remove unused import - Replace ignored ValidateSettings return with require.NoError so tests abort on unexpected validation failures - Add SessionDuration to createMinimalValidSettings for completeness - Remove unused math import from validate_audio.go * fix(conf): reject NaN floats and null bytes in validation Address review feedback from Gemini peer review: - Reject NaN values in EQ filter frequency/Q and dynamic threshold trigger/min fields. NaN comparisons always return false in Go, so NaN would bypass all range checks. YAML supports .nan literals. - Reject null bytes in export path. While Go's os package rejects them at runtime, catching them at validation time gives a clear error message. - Add tests for NaN and null byte edge cases. * fix(api): remove synctest from settings concurrent tests handleSettingsChanges spawns a background goroutine with time.Sleep between control channel sends. When this runs inside a synctest bubble, the sleeping goroutine outlives the test function and triggers the deadlock detector: "main bubble goroutine has exited but blocked goroutines remain". Replace synctest.Test with regular WaitGroup.Go for the two affected scenarios. These tests verify data consistency under concurrent access, not timing behavior, so synctest provides no benefit here. * fix(conf): treat zero realtime interval as default for upgrade compat Existing configs may have interval: 0 explicitly set. Rather than failing validation on upgrade, coerce 0 to the default (15s) with a warning log, matching the SpeciesConfig.Interval pattern where 0 means "use default". Negative values remain rejected.
1 parent 2e717d2 commit 1bbb31e

10 files changed

Lines changed: 887 additions & 74 deletions

internal/api/v2/settings_concurrent_test.go

Lines changed: 45 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"net/http/httptest"
2222
"sync"
2323
"testing"
24-
"testing/synctest"
2524

2625
"github.com/labstack/echo/v4"
2726
"github.com/stretchr/testify/assert"
@@ -260,78 +259,66 @@ func TestRaceConditionScenarios(t *testing.T) {
260259
description: "Verify reads during writes return consistent data",
261260
scenario: func(t *testing.T, controller *Controller) {
262261
t.Helper()
263-
// Use synctest.Test for deterministic read/write concurrency (Go 1.25)
264-
// This ensures predictable execution order without timing dependencies
265-
synctest.Test(t, func(t *testing.T) {
266-
t.Helper()
267-
var wg sync.WaitGroup
262+
// Cannot use synctest.Test here: handleSettingsChanges spawns a
263+
// background goroutine with time.Sleep that outlives the test
264+
// function, causing synctest's deadlock detector to fire.
265+
var wg sync.WaitGroup
268266

269-
// Start a write using WaitGroup.Go() (Go 1.25)
267+
wg.Go(func() {
268+
update := map[string]any{
269+
"summaryLimit": 999,
270+
}
271+
_ = makeSettingsUpdate(t, controller, "dashboard", update)
272+
})
273+
274+
for range 10 {
270275
wg.Go(func() {
271-
update := map[string]any{
272-
"summaryLimit": 999,
273-
}
274-
_ = makeSettingsUpdate(t, controller, "dashboard", update)
276+
req := httptest.NewRequest(http.MethodGet, "/api/v2/settings/dashboard", http.NoBody)
277+
rec := httptest.NewRecorder()
278+
ctx := controller.Echo.NewContext(req, rec)
279+
ctx.SetParamNames("section")
280+
ctx.SetParamValues("dashboard")
281+
282+
err := controller.GetSectionSettings(ctx)
283+
require.NoError(t, err)
284+
assert.Equal(t, http.StatusOK, rec.Code)
285+
286+
var response map[string]any
287+
err = json.Unmarshal(rec.Body.Bytes(), &response)
288+
require.NoError(t, err)
275289
})
290+
}
276291

277-
// Perform multiple reads concurrently with the write
278-
for range 10 {
279-
wg.Go(func() {
280-
req := httptest.NewRequest(http.MethodGet, "/api/v2/settings/dashboard", http.NoBody)
281-
rec := httptest.NewRecorder()
282-
ctx := controller.Echo.NewContext(req, rec)
283-
ctx.SetParamNames("section")
284-
ctx.SetParamValues("dashboard")
285-
286-
err := controller.GetSectionSettings(ctx)
287-
require.NoError(t, err)
288-
assert.Equal(t, http.StatusOK, rec.Code)
289-
290-
// Parse response to verify it's valid JSON
291-
var response map[string]any
292-
err = json.Unmarshal(rec.Body.Bytes(), &response)
293-
require.NoError(t, err)
294-
})
295-
}
296-
297-
wg.Wait()
298-
synctest.Wait() // Ensure all bubble goroutines complete
299-
})
292+
wg.Wait()
300293
},
301294
},
302295
{
303296
name: "Conflicting updates to nested fields",
304297
description: "Verify nested field updates don't corrupt parent objects",
305298
scenario: func(t *testing.T, controller *Controller) {
306299
t.Helper()
307-
// Use synctest.Test for deterministic nested field updates (Go 1.25)
308-
synctest.Test(t, func(t *testing.T) {
309-
t.Helper()
310-
var wg sync.WaitGroup
311-
312-
// Update different nested fields concurrently using WaitGroup.Go() (Go 1.25)
313-
wg.Go(func() {
314-
update := map[string]any{
315-
"thumbnails": map[string]any{
316-
"summary": true,
317-
},
318-
}
319-
_ = makeSettingsUpdate(t, controller, "dashboard", update)
320-
})
300+
var wg sync.WaitGroup
321301

322-
wg.Go(func() {
323-
update := map[string]any{
324-
"thumbnails": map[string]any{
325-
"recent": false,
326-
},
327-
}
328-
_ = makeSettingsUpdate(t, controller, "dashboard", update)
329-
})
302+
wg.Go(func() {
303+
update := map[string]any{
304+
"thumbnails": map[string]any{
305+
"summary": true,
306+
},
307+
}
308+
_ = makeSettingsUpdate(t, controller, "dashboard", update)
309+
})
330310

331-
wg.Wait()
332-
synctest.Wait() // Ensure all bubble goroutines complete
311+
wg.Go(func() {
312+
update := map[string]any{
313+
"thumbnails": map[string]any{
314+
"recent": false,
315+
},
316+
}
317+
_ = makeSettingsUpdate(t, controller, "dashboard", update)
333318
})
334319

320+
wg.Wait()
321+
335322
// Verify both fields were updated
336323
settings := controller.Settings
337324
assert.NotNil(t, settings.Realtime.Dashboard.Thumbnails)

internal/api/v2/settings_malicious_test.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestMaliciousInputData(t *testing.T) {
4949
"path": "../../../etc/passwd",
5050
},
5151
},
52-
description: "Should accept path but file operations should validate",
52+
description: "Should reject path traversal at validation time",
5353
},
5454
{
5555
name: "Command injection attempt",
@@ -139,17 +139,24 @@ func TestMaliciousInputData(t *testing.T) {
139139
ctx.SetParamNames("section")
140140
ctx.SetParamValues(tt.section)
141141

142-
// The update should succeed (we accept the data)
142+
// The update should either succeed or reject malicious input
143143
err = controller.UpdateSectionSettings(ctx)
144144
if err != nil {
145-
// Some malicious inputs might be rejected, which is also fine
145+
// Some malicious inputs might be rejected via error return, which is fine
146146
if httpErr, ok := errors.AsType[*echo.HTTPError](err); ok && httpErr.Code == http.StatusBadRequest {
147-
t.Logf("Input rejected as expected: %v", err)
147+
t.Logf("Input rejected via error: %v", err)
148148
return
149149
}
150150
}
151151

152-
// If accepted, verify the response is valid
152+
// Validation may also reject via response code (HandleError writes 400
153+
// to recorder without returning an error). Both 200 and 400 are acceptable
154+
// outcomes for malicious input: 200 means the data was safely accepted,
155+
// 400 means it was correctly rejected at validation time.
156+
if rec.Code == http.StatusBadRequest {
157+
t.Logf("Input rejected at validation: status %d", rec.Code)
158+
return
159+
}
153160
assert.Equal(t, http.StatusOK, rec.Code)
154161
t.Logf("%s: %s", tt.name, tt.description)
155162
})

internal/api/v2/settings_update_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ func TestAudioExportPartialUpdate(t *testing.T) {
419419
settings := controller.Settings
420420
assert.Equal(t, "mp3", settings.Realtime.Audio.Export.Type) // Changed
421421
assert.True(t, settings.Realtime.Audio.Export.Enabled) // Preserved
422-
assert.Equal(t, "/clips", settings.Realtime.Audio.Export.Path) // Preserved
422+
assert.Equal(t, "clips", settings.Realtime.Audio.Export.Path) // Preserved
423423
assert.Equal(t, "192k", settings.Realtime.Audio.Export.Bitrate) // Preserved
424424
}
425425

internal/api/v2/test_helpers.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func getTestSettings(t *testing.T) *conf.Settings {
4343
settings := &conf.Settings{}
4444

4545
// Initialize with valid defaults
46+
settings.Realtime.Interval = 15 // Must be positive after hardening
4647
settings.Realtime.Dashboard.SummaryLimit = 100 // Valid range: 10-1000
4748
settings.Realtime.Dashboard.Thumbnails.Summary = true
4849
settings.Realtime.Dashboard.Thumbnails.Recent = true
@@ -63,6 +64,7 @@ func getTestSettings(t *testing.T) *conf.Settings {
6364
settings.BirdNET.Longitude = testNewYorkLongitude
6465
settings.BirdNET.Sensitivity = 1.0
6566
settings.BirdNET.Threshold = 0.8
67+
settings.BirdNET.Locale = "en"
6668
settings.BirdNET.RangeFilter.Model = "latest"
6769
settings.BirdNET.RangeFilter.Threshold = 0.03
6870

@@ -73,7 +75,7 @@ func getTestSettings(t *testing.T) *conf.Settings {
7375
}}
7476
settings.Realtime.Audio.Export.Enabled = true
7577
settings.Realtime.Audio.Export.Type = "wav"
76-
settings.Realtime.Audio.Export.Path = "/clips"
78+
settings.Realtime.Audio.Export.Path = "clips"
7779
settings.Realtime.Audio.Export.Bitrate = "192k"
7880
settings.Realtime.Audio.Export.Length = 15
7981

internal/conf/audio_source_validation_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ func TestAudioSourceConfig_Validate_PerSourceEQ(t *testing.T) {
217217
Equalizer: &EqualizerSettings{
218218
Enabled: true,
219219
Filters: []EqualizerFilter{
220-
{Type: "HighPass", Frequency: 100},
220+
{Type: "HighPass", Frequency: 100, Q: 0.707},
221221
},
222222
},
223223
}

internal/conf/test_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func GetTestSettings() *Settings {
2929
settings.Realtime.Dashboard.Thumbnails.Summary = false
3030
settings.Realtime.Dashboard.Thumbnails.Recent = true
3131
settings.Realtime.Dashboard.Thumbnails.ImageProvider = "avicommons"
32-
settings.Realtime.Dashboard.Thumbnails.FallbackPolicy = "none"
32+
settings.Realtime.Dashboard.Thumbnails.FallbackPolicy = RetentionPolicyNone
3333

3434
// Other realtime settings
3535
settings.Realtime.Interval = 15

internal/conf/validate.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ var validRetentionPolicies = []string{
2929
RetentionPolicyUsage,
3030
}
3131

32+
// htmlTagPattern matches HTML tags for sanitization.
33+
var htmlTagPattern = regexp.MustCompile(`<[^>]*>`)
34+
35+
// sanitizeStringField strips HTML tags from a string field as defense in depth.
36+
// Returns the sanitized string.
37+
func sanitizeStringField(s string) string {
38+
return htmlTagPattern.ReplaceAllString(s, "")
39+
}
40+
3241
// Precompiled regular expressions for validation
3342
var (
3443
// birdweatherIDPattern validates Birdweather ID format (24 alphanumeric characters)
@@ -153,6 +162,15 @@ func firstValidationError(result ValidationResult, validationType string) error
153162
func ValidateSettings(settings *Settings) error {
154163
ve := ValidationError{}
155164

165+
// Sanitize Main.Name: strip HTML tags as defense in depth
166+
settings.Main.Name = sanitizeStringField(settings.Main.Name)
167+
168+
// Default empty BirdNET locale to "en" so downstream label loading
169+
// always has a valid locale to work with.
170+
if settings.BirdNET.Locale == "" {
171+
settings.BirdNET.Locale = "en"
172+
}
173+
156174
// Validate BirdNET settings
157175
if err := validateBirdNETSettings(&settings.BirdNET); err != nil {
158176
ve.Errors = append(ve.Errors, err.Error())

0 commit comments

Comments
 (0)