Skip to content

Commit beb33f6

Browse files
Merge pull request #84 from Abo-Omar-74/security
security: address gosec findings and improve HTTP security headers
2 parents 5d23b6f + c94416b commit beb33f6

15 files changed

+101
-413
lines changed

internal/app/handlers.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,7 @@ func (app *Application) healthcheckHandler(w http.ResponseWriter, r *http.Reques
5555
if !ready {
5656
w.WriteHeader(http.StatusInternalServerError)
5757
}
58-
json.NewEncoder(w).Encode(status)
58+
if err := json.NewEncoder(w).Encode(status); err != nil {
59+
app.Logger.Warn("failed to write healthcheck response", "error", err)
60+
}
5961
}

internal/app/routes.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ func (app *Application) Routes(ctx context.Context) http.Handler {
5858
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
5959
router.Handler(http.MethodGet, "/metrics", middleware.NewCachedPromHandler(ctx, prometheus.DefaultGatherer, 10*time.Second))
6060

61-
// Wrap router with Sentry middleware
61+
// Wrap router with Sentry and securityHeaders middlewares
6262
// Return wrapped httprouter instance.
63-
return middleware.SentryMiddleware(router)
63+
handler := middleware.SentryMiddleware(router)
64+
return middleware.SecurityHeaders(handler)
6465
}

internal/config/backoff_time_store.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ func DoWithBackoff(ctx context.Context, client *http.Client, req *http.Request,
9090
}
9191

9292
func calculateNextRetryAt(backoff time.Duration) time.Time {
93+
// Adding jitter for backoff retries. Cryptographic randomness is not required.
94+
// #nosec G404
9395
jitter := time.Duration(rand.Float64() * float64(backoff) * JITTER_FACTOR)
9496
backoff += jitter
9597
if backoff > MAX_BACKOFF {

internal/config/backoff_time_store_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func TestBackoffStore(t *testing.T) {
164164
before, _ := store.NextRetryAt(serverID)
165165
store.UpdateBackoff(serverID)
166166
after, _ := store.NextRetryAt(serverID)
167-
167+
168168
if !after.After(before) {
169169
t.Errorf("expected NextRetryAt to move forward after backoff increase, got before=%v after=%v", before, after)
170170
}

internal/config/config_loader.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log/slog"
1010
"net/http"
1111
"os"
12+
"path/filepath"
1213
"time"
1314

1415
"github.com/getsentry/sentry-go"
@@ -67,11 +68,20 @@ func refreshConfig(ctx context.Context, client *http.Client, configURL, configAu
6768
// LoadConfigFromFile reads a JSON configuration file from disk and unmarshals it
6869
// into a list of OBA server configurations (`[]models.ObaServer`).
6970
//
71+
// For security reasons, only files named `config.json` are allowed to be loaded.
72+
// Without this restriction, a user could supply any file path on the machine
73+
// (e.g., /etc/passwd), and the application would attempt to read it.
74+
//
7075
// On error, it reports issues to Sentry and returns a descriptive error.
7176
//
7277
// This function is used when the application is configured to load its server list
7378
// from a static file using the --config-file flag.
7479
func loadConfigFromFile(filePath string) ([]models.ObaServer, error) {
80+
if filepath.Base(filePath) != "config.json" {
81+
return nil, fmt.Errorf("invalid config file name: %s (only config.json is allowed)", filePath)
82+
}
83+
84+
// #nosec G304 - file path validated by restricting to config.json
7585
data, err := os.ReadFile(filePath)
7686
if err != nil {
7787
report.ReportErrorWithSentryOptions(err, report.SentryReportOptions{

internal/config/config_loader_test.go

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"net/http/httptest"
1212
"os"
13+
"path/filepath"
1314
"strings"
1415
"testing"
1516
"time"
@@ -29,24 +30,20 @@ func TestLoadConfigFromFile(t *testing.T) {
2930
"gtfs_rt_api_key": "",
3031
"gtfs_rt_api_value": ""
3132
}]`
32-
tmpFile, err := os.CreateTemp("", "config-*.json")
33-
if err != nil {
34-
t.Fatalf("Failed to create temporary file: %v", err)
35-
}
36-
defer os.Remove(tmpFile.Name())
3733

38-
if _, err := tmpFile.Write([]byte(content)); err != nil {
39-
t.Fatalf("Failed to write to temporary file: %v", err)
34+
dir := t.TempDir()
35+
fp := filepath.Join(dir, "config.json")
36+
if err := os.WriteFile(fp, []byte(content), 0o600); err != nil {
37+
t.Fatalf("write config.json: %v", err)
4038
}
41-
tmpFile.Close()
4239

43-
servers, err := loadConfigFromFile(tmpFile.Name())
40+
servers, err := loadConfigFromFile(fp)
4441
if err != nil {
4542
t.Fatalf("loadConfigFromFile failed: %v", err)
4643
}
4744

4845
if len(servers) != 1 {
49-
t.Fatalf("Expected 1 server, got %d", len(servers))
46+
t.Fatalf("expected 1 server, got %d", len(servers))
5047
}
5148

5249
expected := models.ObaServer{
@@ -62,33 +59,29 @@ func TestLoadConfigFromFile(t *testing.T) {
6259
}
6360

6461
if servers[0] != expected {
65-
t.Errorf("Expected server %+v, got %+v", expected, servers[0])
62+
t.Errorf("expected %+v, got %+v", expected, servers[0])
6663
}
6764
})
6865

6966
t.Run("InvalidJSON", func(t *testing.T) {
67+
dir := t.TempDir()
68+
fp := filepath.Join(dir, "config.json")
7069
content := `{ this is not valid JSON }`
71-
tmpFile, err := os.CreateTemp("", "invalid-config-*.json")
72-
if err != nil {
73-
t.Fatalf("Failed to create temporary file: %v", err)
70+
if err := os.WriteFile(fp, []byte(content), 0o600); err != nil {
71+
t.Fatalf("write invalid config.json: %v", err)
7472
}
75-
defer os.Remove(tmpFile.Name())
7673

77-
if _, err := tmpFile.Write([]byte(content)); err != nil {
78-
t.Fatalf("Failed to write to temporary file: %v", err)
79-
}
80-
tmpFile.Close()
81-
82-
_, err = loadConfigFromFile(tmpFile.Name())
83-
if err == nil {
84-
t.Errorf("Expected error with invalid JSON, got none")
74+
if _, err := loadConfigFromFile(fp); err == nil {
75+
t.Errorf("expected error with invalid JSON, got none")
8576
}
8677
})
8778

8879
t.Run("NonExistentFile", func(t *testing.T) {
89-
_, err := loadConfigFromFile("non-existent-file.json")
90-
if err == nil {
91-
t.Errorf("Expected error for non-existent file, got none")
80+
dir := t.TempDir()
81+
fp := filepath.Join(dir, "config.json")
82+
83+
if _, err := loadConfigFromFile(fp); err == nil {
84+
t.Errorf("expected error for non-existent file, got none")
9285
}
9386
})
9487
}

internal/config/test_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ type mockRoundTripper struct {
1010
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
1111
m.calls++
1212
return m.handler(req)
13-
}
13+
}

internal/gtfs/gtfs_bundles.go

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"log/slog"
88
"net/http"
99
"net/url"
10-
"os"
1110
"strconv"
1211
"sync"
1312
"time"
@@ -204,34 +203,6 @@ func storeGTFSBundle(staticBundle *remoteGtfs.Static, serverID int, staticStore
204203
return nil
205204
}
206205

207-
// ParseGTFSFromCache reads a GTFS bundle from the cache and parses it into a gtfs.Static object.
208-
// It returns the parsed static data or an error if parsing fails.
209-
func ParseGTFSFromCache(cachePath string, serverID int) (*remoteGtfs.Static, error) {
210-
fileBytes, err := os.ReadFile(cachePath)
211-
if err != nil {
212-
report.ReportErrorWithSentryOptions(err, report.SentryReportOptions{
213-
Tags: utils.MakeMap("server_id", strconv.Itoa(serverID)),
214-
ExtraContext: map[string]interface{}{
215-
"cache_path": cachePath,
216-
},
217-
})
218-
return nil, err
219-
}
220-
221-
staticData, err := remoteGtfs.ParseStatic(fileBytes, remoteGtfs.ParseStaticOptions{})
222-
if err != nil {
223-
report.ReportErrorWithSentryOptions(err, report.SentryReportOptions{
224-
Tags: utils.MakeMap("server_id", strconv.Itoa(serverID)),
225-
ExtraContext: map[string]interface{}{
226-
"cache_path": cachePath,
227-
},
228-
})
229-
return nil, err
230-
}
231-
232-
return staticData, nil
233-
}
234-
235206
// getStopLocationsByIDs retrieves stop locations by their IDs from the GTFS cache.
236207
// It returns a map of stop IDs to gtfs.Stop objects.
237208

internal/gtfs/gtfs_bundles_test.go

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -144,48 +144,6 @@ func TestGetStopLocationsByIDs(t *testing.T) {
144144
})
145145
}
146146

147-
func TestParseGTFSFromCache(t *testing.T) {
148-
server := models.ObaServer{ID: 1, Name: "Test"}
149-
150-
tests := []struct {
151-
name string
152-
cachePath string
153-
expectErr bool
154-
}{
155-
{
156-
name: "Valid GTFS file",
157-
cachePath: "../../testdata/gtfs.zip",
158-
expectErr: false,
159-
},
160-
{
161-
name: "Invalid path",
162-
cachePath: "../../testdata/does-not-exist.zip",
163-
expectErr: true,
164-
},
165-
}
166-
167-
for _, tc := range tests {
168-
t.Run(tc.name, func(t *testing.T) {
169-
staticData, err := ParseGTFSFromCache(tc.cachePath, server.ID)
170-
if tc.expectErr {
171-
if err == nil {
172-
t.Fatal("expected error but got nil")
173-
}
174-
return
175-
}
176-
if err != nil {
177-
t.Fatalf("unexpected error: %v", err)
178-
}
179-
if staticData == nil {
180-
t.Fatal("expected staticData to be non-nil")
181-
}
182-
if len(staticData.Stops) == 0 {
183-
t.Error("expected at least one stop in GTFS data, got 0")
184-
}
185-
})
186-
}
187-
}
188-
189147
func TestFetchAndStoreGTFSRTFeed(t *testing.T) {
190148
t.Run("Success Case", func(t *testing.T) {
191149
mockServer := setupGtfsServer(t, "gtfs_rt_feed_vehicles.pb")

internal/gtfs/test_helpers.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ func setupGtfsServer(t *testing.T, fixturePath string) *httptest.Server {
1313

1414
gtfsFixturePath := getFixturePath(t, fixturePath)
1515

16+
// Safe: absPath is only used in local tests and not from user input.
17+
// #nosec G304
1618
gtfsFileData, err := os.ReadFile(gtfsFixturePath)
1719
if err != nil {
1820
t.Fatalf("Failed to read GTFS-RT fixture file: %v", err)
1921
}
2022

2123
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2224
w.Header().Set("Content-Type", "application/octet-stream")
25+
// Writing to ResponseWriter in tests, error can be safely ignored.
26+
// #nosec G104
2327
w.Write(gtfsFileData)
2428
}))
2529
}
@@ -42,7 +46,8 @@ func readFixture(t *testing.T, fixturePath string) []byte {
4246
if err != nil {
4347
t.Fatalf("Failed to get absolute path to testdata/%s: %v", fixturePath, err)
4448
}
45-
49+
// Safe: absPath is only used in local tests and not from user input.
50+
// #nosec G304
4651
data, err := os.ReadFile(absPath)
4752
if err != nil {
4853
t.Fatalf("Failed to read fixture file: %v", err)

0 commit comments

Comments
 (0)