Harden API security: input validation, safe auth extraction, new tests, and deploy config

Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-02 09:48:01 -06:00
parent 56d6fa4514
commit 7690f07a2b
123 changed files with 8321 additions and 750 deletions

View File

@@ -117,6 +117,9 @@ func (c *FCMClient) Send(ctx context.Context, tokens []string, title, message st
// Log individual results
for i, result := range fcmResp.Results {
if i >= len(tokens) {
break
}
if result.Error != "" {
log.Error().
Str("token", truncateToken(tokens[i])).

186
internal/push/fcm_test.go Normal file
View File

@@ -0,0 +1,186 @@
package push
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestFCMClient creates an FCMClient pointing at the given test server URL.
func newTestFCMClient(serverURL string) *FCMClient {
return &FCMClient{
serverKey: "test-server-key",
httpClient: http.DefaultClient,
}
}
// serveFCMResponse creates an httptest.Server that returns the given FCMResponse as JSON.
func serveFCMResponse(t *testing.T, resp FCMResponse) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
}
// sendWithEndpoint is a helper that sends an FCM notification using a custom endpoint
// (the test server) instead of the real FCM endpoint. This avoids modifying the
// production code to be testable and instead temporarily overrides the client's HTTP
// transport to redirect requests to our test server.
func sendWithEndpoint(client *FCMClient, server *httptest.Server, ctx context.Context, tokens []string, title, message string, data map[string]string) error {
// Override the HTTP client to redirect all requests to the test server
client.httpClient = server.Client()
// We need to intercept the request and redirect it to our test server.
// Use a custom RoundTripper that rewrites the URL.
originalTransport := server.Client().Transport
client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
// Rewrite the URL to point to the test server
req.URL.Scheme = "http"
req.URL.Host = server.Listener.Addr().String()
if originalTransport != nil {
return originalTransport.RoundTrip(req)
}
return http.DefaultTransport.RoundTrip(req)
})
return client.Send(ctx, tokens, title, message, data)
}
// roundTripFunc is a function that implements http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFCMSend_MoreResultsThanTokens_NoPanic(t *testing.T) {
// FCM returns 5 results but we only sent 2 tokens.
// Before the bounds check fix, this would panic with index out of range.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 2,
Failure: 3,
Results: []FCMResult{
{MessageID: "msg1"},
{MessageID: "msg2"},
{Error: "InvalidRegistration"},
{Error: "NotRegistered"},
{Error: "InvalidRegistration"},
},
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111", "token-bbb-222"}
// This must not panic
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
assert.NoError(t, err)
}
func TestFCMSend_FewerResultsThanTokens_NoPanic(t *testing.T) {
// FCM returns fewer results than tokens we sent.
// This is also a malformed response but should not panic.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 1,
Failure: 0,
Results: []FCMResult{
{MessageID: "msg1"},
},
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111", "token-bbb-222", "token-ccc-333"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
assert.NoError(t, err)
}
func TestFCMSend_EmptyResponse_NoPanic(t *testing.T) {
// FCM returns an empty Results slice.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 0,
Failure: 0,
Results: []FCMResult{},
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
// No panic expected. The function returns nil because fcmResp.Success == 0
// and fcmResp.Failure == 0 (the "all failed" check requires Failure > 0).
assert.NoError(t, err)
}
func TestFCMSend_NilResultsSlice_NoPanic(t *testing.T) {
// FCM returns a response with nil Results (e.g., malformed JSON).
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 0,
Failure: 1,
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
// Should return error because Success == 0 and Failure > 0
assert.Error(t, err)
assert.Contains(t, err.Error(), "all FCM notifications failed")
}
func TestFCMSend_EmptyTokens_ReturnsNil(t *testing.T) {
// Verify the early return for empty tokens.
client := &FCMClient{
serverKey: "test-key",
httpClient: http.DefaultClient,
}
err := client.Send(context.Background(), []string{}, "Test", "Body", nil)
assert.NoError(t, err)
}
func TestFCMSend_ResultsWithErrorsMatchTokens(t *testing.T) {
// Normal case: results count matches tokens count, all with errors.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 0,
Failure: 2,
Results: []FCMResult{
{Error: "InvalidRegistration"},
{Error: "NotRegistered"},
},
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111", "token-bbb-222"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "all FCM notifications failed")
}