Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ func gtfsConfigFromData(gtfsCfgData appconf.GtfsConfigData) gtfs.Config {
GTFSDataPath: gtfsCfgData.GTFSDataPath,
Env: gtfsCfgData.Env,

EnableGTFSTidy: gtfsCfgData.EnableGTFSTidy,
EnableGTFSTidy: gtfsCfgData.EnableGTFSTidy,
StopConsolidationURL: gtfsCfgData.StopConsolidationURL,
}

for _, feedData := range gtfsCfgData.RTFeeds {
Expand Down Expand Up @@ -321,6 +322,9 @@ func dumpConfigJSON(cfg appconf.Config, gtfsCfg gtfs.Config) {
staticFeed["auth-header-name"] = gtfsCfg.StaticAuthHeaderKey
staticFeed["auth-header-value"] = staticAuthValue
}
if gtfsCfg.StopConsolidationURL != "" {
staticFeed["stop-consolidation-url"] = gtfsCfg.StopConsolidationURL
}

// Build JSON config structure
jsonConfig := map[string]any{
Expand Down
30 changes: 30 additions & 0 deletions cmd/api/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -747,3 +747,33 @@ func TestDumpConfigJSON_WithNoProtectedAPIKeys(t *testing.T) {
require.NoError(t, json.Unmarshal(output.Bytes(), &parsed))
assert.Equal(t, "***REDACTED*** (0 keys)", parsed["protected-api-keys"])
}

func TestStopConsolidationURLReachesGTFSConfigAndDump(t *testing.T) {
gtfsCfg := gtfsConfigFromData(appconf.GtfsConfigData{
GtfsURL: "https://example.com/gtfs.zip",
StopConsolidationURL: "https://example.com/StopConsolidation.txt",
})
assert.Equal(t, "https://example.com/StopConsolidation.txt", gtfsCfg.StopConsolidationURL)

parsed := dumpConfigToMap(t, appconf.Config{}, gtfsCfg)

staticFeed, ok := parsed["gtfs-static-feed"].(map[string]any)
require.True(t, ok, "gtfs-static-feed should be a map")
assert.Equal(t, "https://example.com/StopConsolidation.txt", staticFeed["stop-consolidation-url"])
}

func dumpConfigToMap(t *testing.T, cfg appconf.Config, gtfsCfg gtfs.Config) map[string]any {
t.Helper()
oldStdout := os.Stdout
defer func() { os.Stdout = oldStdout }()
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w

dumpConfigJSON(cfg, gtfsCfg)
_ = w.Close()

var parsed map[string]any
require.NoError(t, json.NewDecoder(r).Decode(&parsed))
return parsed
}
8 changes: 5 additions & 3 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func main() {
flag.StringVar(&protectedApiKeysFlag, "protected-api-keys", "", "Comma separated API keys for protected endpoints (default: GTFS_PROTECTED_API_KEYS, or a test key in development and test)")
flag.IntVar(&cfg.RateLimit, "rate-limit", 100, "Requests per second across the entire service (global shared bucket; exempt keys bypass it)")
flag.StringVar(&gtfsCfg.GtfsURL, "gtfs-url", "https://www.soundtransit.org/GTFS-rail/40_gtfs.zip", "URL for a static GTFS zip file")
flag.StringVar(&gtfsCfg.StopConsolidationURL, "stop-consolidation-url", "", "Optional URL or path for a Java OneBusAway stop consolidation file")
flag.StringVar(&gtfsCfg.StaticAuthHeaderKey, "gtfs-static-auth-header-name", "", "Optional header name for static GTFS feed auth")
flag.StringVar(&gtfsCfg.StaticAuthHeaderValue, "gtfs-static-auth-header-value", "", "Optional header value for static GTFS feed auth")
flag.StringVar(&cliFeedTripUpdatesURL, "trip-updates-url", "https://api.pugetsound.onebusaway.org/api/gtfs_realtime/trip-updates-for-agency/40.pb?key=org.onebusaway.iphone", "URL for a GTFS-RT trip updates feed")
Expand Down Expand Up @@ -130,9 +131,10 @@ func main() {
LogFormat: "text",
RateLimit: cfg.RateLimit,
GtfsStaticFeed: appconf.GtfsStaticFeed{
URL: gtfsCfg.GtfsURL,
AuthHeaderName: gtfsCfg.StaticAuthHeaderKey,
AuthHeaderValue: gtfsCfg.StaticAuthHeaderValue,
URL: gtfsCfg.GtfsURL,
StopConsolidationURL: gtfsCfg.StopConsolidationURL,
AuthHeaderName: gtfsCfg.StaticAuthHeaderKey,
AuthHeaderValue: gtfsCfg.StaticAuthHeaderValue,
},
GtfsRtFeeds: []appconf.GtfsRtFeed{
{
Expand Down
21 changes: 21 additions & 0 deletions cmd/api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,24 @@ func TestMain_CommandLineFlagsPassValidation(t *testing.T) {
})
}
}

func TestMain_StopConsolidationURLFlag(t *testing.T) {
tests := []struct {
name string
url string
wantExitCode int
wantOutput string
}{
{"URL reaches the config", "https://example.com/StopConsolidation.txt", 0, `"stop-consolidation-url": "https://example.com/StopConsolidation.txt"`},
{"file URL is rejected", "file:///etc/StopConsolidation.txt", 1, "gtfs-static-feed.stop-consolidation-url"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
exitCode, output := runMainWithArgs(t, "-dump-config -stop-consolidation-url "+tc.url)

assert.Equal(t, tc.wantExitCode, exitCode, output)
assert.Contains(t, output, tc.wantOutput)
})
}
}
4 changes: 4 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@
"type": "boolean",
"description": "Enable GTFS tidying with gtfstidy tool (requires gtfstidy to be installed)",
"default": false
},
"stop-consolidation-url": {
"type": "string",
"description": "Optional Java OneBusAway stop consolidation file (http/https URL or local file path). Each line lists a canonical stop ID, then the stop IDs merged into it, in <agency>_<id> form, where the agency is the feed's first agency in agency.txt"
}
},
"required": ["url"],
Expand Down
42 changes: 24 additions & 18 deletions internal/appconf/json_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ type GtfsStaticFeed struct {
AuthHeaderName string `json:"auth-header-name"`
AuthHeaderValue string `json:"auth-header-value"`
EnableGTFSTidy bool `json:"enable-gtfs-tidy"`
// StopConsolidationURL is a Java OneBusAway stop consolidation file:
// an http(s) URL or a local path.
StopConsolidationURL string `json:"stop-consolidation-url"`
}

// GtfsRtFeed represents a single GTFS-RT feed configuration
Expand Down Expand Up @@ -205,26 +208,27 @@ func (j *JSONConfig) Validate() error {
return fmt.Errorf("both auth-header-name and auth-header-value must be provided together for gtfs-static-feed")
}

// Validate GtfsStaticFeed.URL to prevent file:// URLs and other security issues
if j.GtfsStaticFeed.URL != "" {
// Block file:// URLs (case-insensitive)
if strings.HasPrefix(strings.ToLower(j.GtfsStaticFeed.URL), "file://") {
return fmt.Errorf("file:// URLs are not allowed for gtfs-static-feed.url for security reasons")
}

// For HTTP(S) URLs, no path checks needed
if strings.HasPrefix(j.GtfsStaticFeed.URL, "http://") ||
strings.HasPrefix(j.GtfsStaticFeed.URL, "https://") {
return nil
}

// For file paths, validate for path traversal
if err := validatePath(j.GtfsStaticFeed.URL, "gtfs-static-feed.url"); err != nil {
return err
}
if err := validateFeedLocation(j.GtfsStaticFeed.URL, "gtfs-static-feed.url"); err != nil {
return err
}
return validateFeedLocation(j.GtfsStaticFeed.StopConsolidationURL, "gtfs-static-feed.stop-consolidation-url")
}

return nil
// validateFeedLocation checks a URL or local path Maglev reads a feed file
// from, blocking file:// URLs and path traversal.
func validateFeedLocation(location, fieldName string) error {
if location == "" {
return nil
}
// Block file:// URLs (case-insensitive)
if strings.HasPrefix(strings.ToLower(location), "file://") {
return fmt.Errorf("file:// URLs are not allowed for %s for security reasons", fieldName)
}
// For HTTP(S) URLs, no path checks needed
if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") {
return nil
}
return validatePath(location, fieldName)
}

// validatePath checks a file path for security issues
Expand Down Expand Up @@ -294,6 +298,7 @@ type GtfsConfigData struct {
GTFSDataPath string
Env Environment
EnableGTFSTidy bool
StopConsolidationURL string
}

// ToGtfsConfigData converts JSONConfig to GtfsConfigData
Expand All @@ -305,6 +310,7 @@ func (j *JSONConfig) ToGtfsConfigData() (GtfsConfigData, error) {
GTFSDataPath: j.DataPath,
Env: EnvFlagToEnvironment(j.Env),
EnableGTFSTidy: j.GtfsStaticFeed.EnableGTFSTidy,
StopConsolidationURL: j.GtfsStaticFeed.StopConsolidationURL,
}

seen := make(map[string]struct{})
Expand Down
53 changes: 53 additions & 0 deletions internal/appconf/json_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,3 +730,56 @@ func TestToGtfsConfigData_DuplicateIDs(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "duplicate feed ID")
}

func TestToGtfsConfigData_StopConsolidationURL(t *testing.T) {
jsonConfig := &JSONConfig{
GtfsStaticFeed: GtfsStaticFeed{
URL: "https://example.com/gtfs.zip",
StopConsolidationURL: "https://example.com/StopConsolidation.txt",
},
}

gtfsConfig, err := jsonConfig.ToGtfsConfigData()

require.NoError(t, err)
assert.Equal(t, "https://example.com/StopConsolidation.txt", gtfsConfig.StopConsolidationURL)
}

func TestValidate_StopConsolidationURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr string
}{
{name: "https URL", url: "https://example.com/StopConsolidation.txt"},
{name: "local path", url: "/data/StopConsolidation.txt"},
{name: "file URL", url: "FILE:///etc/passwd", wantErr: "file:// URLs are not allowed for gtfs-static-feed.stop-consolidation-url"},
{name: "path traversal", url: "../../secret.txt", wantErr: "gtfs-static-feed.stop-consolidation-url"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &JSONConfig{
Port: 4000,
Env: "development",
ApiKeys: []string{"test"},
ProtectedApiKeys: []string{"test"},
RateLimit: 100,
GtfsStaticFeed: GtfsStaticFeed{
URL: "https://example.com/gtfs.zip",
StopConsolidationURL: tt.url,
},
LogLevel: "info",
LogFormat: "text",
}

err := config.Validate()

if tt.wantErr == "" {
assert.NoError(t, err)
return
}
assert.ErrorContains(t, err, tt.wantErr)
})
}
}
Loading