fix(security): redact sensitive urls in logs
- commit
- 0ccc01d90549a1a25872b85d961f805d9bb9165d
- author
- brice · · 2026-06-20 09:51 UTC
- committer
- brice · · 2026-06-20 09:58 UTC
- parent
- 2be8535
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +176 -30
M internal/application/usecase/manage_favorites.go +9 -9
| @@ -59,7 +59,7 @@ type AddFavoriteInput struct { | |||
| 59 | 59 | // Add creates a new favorite. | |
| 60 | 60 | func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { | |
| 61 | 61 | log := logging.FromContext(ctx) | |
| 62 | - | log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite") | |
| 62 | + | log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite") | |
| 63 | 63 | ||
| 64 | 64 | // Check if already favorited | |
| 65 | 65 | existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL) | |
| @@ -67,7 +67,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||
| 67 | 67 | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| 68 | 68 | } | |
| 69 | 69 | if existing != nil { | |
| 70 | - | log.Debug().Str("url", input.URL).Msg("URL already favorited") | |
| 70 | + | log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited") | |
| 71 | 71 | return existing, nil | |
| 72 | 72 | } | |
| 73 | 73 | ||
| @@ -95,7 +95,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF | |||
| 95 | 95 | } | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | - | log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added") | |
| 98 | + | log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added") | |
| 99 | 99 | uc.invalidateCache() | |
| 100 | 100 | return fav, nil | |
| 101 | 101 | } | |
| @@ -235,14 +235,14 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||
| 235 | 235 | // RemoveByURL deletes a favorite by its URL. | |
| 236 | 236 | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { | |
| 237 | 237 | log := logging.FromContext(ctx) | |
| 238 | - | log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL") | |
| 238 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL") | |
| 239 | 239 | ||
| 240 | 240 | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) | |
| 241 | 241 | if err != nil { | |
| 242 | 242 | return fmt.Errorf("failed to find favorite: %w", err) | |
| 243 | 243 | } | |
| 244 | 244 | if fav == nil { | |
| 245 | - | log.Debug().Str("url", favoriteURL).Msg("favorite not found") | |
| 245 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found") | |
| 246 | 246 | return nil | |
| 247 | 247 | } | |
| 248 | 248 | ||
| @@ -314,7 +314,7 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (* | |||
| 314 | 314 | // GetByURL finds a favorite by its URL. | |
| 315 | 315 | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { | |
| 316 | 316 | log := logging.FromContext(ctx) | |
| 317 | - | log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL") | |
| 317 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL") | |
| 318 | 318 | ||
| 319 | 319 | return uc.favoriteRepo.FindByURL(ctx, favoriteURL) | |
| 320 | 320 | } | |
| @@ -455,7 +455,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri | |||
| 455 | 455 | title = strings.TrimSpace(title) | |
| 456 | 456 | ||
| 457 | 457 | log := logging.FromContext(ctx) | |
| 458 | - | log.Debug().Str("url", favoriteURL).Msg("toggling favorite") | |
| 458 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite") | |
| 459 | 459 | ||
| 460 | 460 | // Check if already favorited, including legacy rows stored before URL canonicalization. | |
| 461 | 461 | existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) | |
| @@ -468,7 +468,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri | |||
| 468 | 468 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |
| 469 | 469 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |
| 470 | 470 | } | |
| 471 | - | log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 471 | + | log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 472 | 472 | uc.invalidateCache() | |
| 473 | 473 | return &ToggleResult{ | |
| 474 | 474 | Added: false, | |
| @@ -483,7 +483,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri | |||
| 483 | 483 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |
| 484 | 484 | return nil, fmt.Errorf("failed to add favorite: %w", err) | |
| 485 | 485 | } | |
| 486 | - | log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 486 | + | log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 487 | 487 | uc.invalidateCache() | |
| 488 | 488 | return &ToggleResult{ | |
| 489 | 489 | Added: true, | |
| @@ -59,7 +59,7 @@ type AddFavoriteInput struct { | |||||
| 59 | // Add creates a new favorite. | 59 | // Add creates a new favorite. | ||
| 60 | func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { | 60 | func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { | ||
| 61 | log := logging.FromContext(ctx) | 61 | log := logging.FromContext(ctx) | ||
| 62 | - | log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite") | 62 | + | log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite") |
| 63 | 63 | ||||
| 64 | // Check if already favorited | 64 | // Check if already favorited | ||
| 65 | existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL) | 65 | existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL) | ||
| @@ -67,7 +67,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 67 | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | 67 | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | ||
| 68 | } | 68 | } | ||
| 69 | if existing != nil { | 69 | if existing != nil { | ||
| 70 | - | log.Debug().Str("url", input.URL).Msg("URL already favorited") | 70 | + | log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited") |
| 71 | return existing, nil | 71 | return existing, nil | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| @@ -95,7 +95,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF | |||||
| 95 | } | 95 | } | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | - | log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added") | 98 | + | log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added") |
| 99 | uc.invalidateCache() | 99 | uc.invalidateCache() | ||
| 100 | return fav, nil | 100 | return fav, nil | ||
| 101 | } | 101 | } | ||
| @@ -235,14 +235,14 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||||
| 235 | // RemoveByURL deletes a favorite by its URL. | 235 | // RemoveByURL deletes a favorite by its URL. | ||
| 236 | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { | 236 | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { | ||
| 237 | log := logging.FromContext(ctx) | 237 | log := logging.FromContext(ctx) | ||
| 238 | - | log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL") | 238 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL") |
| 239 | 239 | ||||
| 240 | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) | 240 | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) | ||
| 241 | if err != nil { | 241 | if err != nil { | ||
| 242 | return fmt.Errorf("failed to find favorite: %w", err) | 242 | return fmt.Errorf("failed to find favorite: %w", err) | ||
| 243 | } | 243 | } | ||
| 244 | if fav == nil { | 244 | if fav == nil { | ||
| 245 | - | log.Debug().Str("url", favoriteURL).Msg("favorite not found") | 245 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found") |
| 246 | return nil | 246 | return nil | ||
| 247 | } | 247 | } | ||
| 248 | 248 | ||||
| @@ -314,7 +314,7 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (* | |||||
| 314 | // GetByURL finds a favorite by its URL. | 314 | // GetByURL finds a favorite by its URL. | ||
| 315 | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { | 315 | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { | ||
| 316 | log := logging.FromContext(ctx) | 316 | log := logging.FromContext(ctx) | ||
| 317 | - | log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL") | 317 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL") |
| 318 | 318 | ||||
| 319 | return uc.favoriteRepo.FindByURL(ctx, favoriteURL) | 319 | return uc.favoriteRepo.FindByURL(ctx, favoriteURL) | ||
| 320 | } | 320 | } | ||
| @@ -455,7 +455,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri | |||||
| 455 | title = strings.TrimSpace(title) | 455 | title = strings.TrimSpace(title) | ||
| 456 | 456 | ||||
| 457 | log := logging.FromContext(ctx) | 457 | log := logging.FromContext(ctx) | ||
| 458 | - | log.Debug().Str("url", favoriteURL).Msg("toggling favorite") | 458 | + | log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite") |
| 459 | 459 | ||||
| 460 | // Check if already favorited, including legacy rows stored before URL canonicalization. | 460 | // Check if already favorited, including legacy rows stored before URL canonicalization. | ||
| 461 | existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) | 461 | existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) | ||
| @@ -468,7 +468,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri | |||||
| 468 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | 468 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | ||
| 469 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | 469 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | ||
| 470 | } | 470 | } | ||
| 471 | - | log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | 471 | + | log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") |
| 472 | uc.invalidateCache() | 472 | uc.invalidateCache() | ||
| 473 | return &ToggleResult{ | 473 | return &ToggleResult{ | ||
| 474 | Added: false, | 474 | Added: false, | ||
| @@ -483,7 +483,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri | |||||
| 483 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | 483 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | ||
| 484 | return nil, fmt.Errorf("failed to add favorite: %w", err) | 484 | return nil, fmt.Errorf("failed to add favorite: %w", err) | ||
| 485 | } | 485 | } | ||
| 486 | - | log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | 486 | + | log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") |
| 487 | uc.invalidateCache() | 487 | uc.invalidateCache() | ||
| 488 | return &ToggleResult{ | 488 | return &ToggleResult{ | ||
| 489 | Added: true, | 489 | Added: true, | ||
M internal/application/usecase/navigate.go +11 -11
| @@ -124,7 +124,7 @@ type NavigateOutput struct { | |||
| 124 | 124 | func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) { | |
| 125 | 125 | log := logging.FromContext(ctx) | |
| 126 | 126 | log.Debug(). | |
| 127 | - | Str("url", input.URL). | |
| 127 | + | Str("url", logging.RedactURL(input.URL)). | |
| 128 | 128 | Str("pane_id", input.PaneID). | |
| 129 | 129 | Msg("navigating to URL") | |
| 130 | 130 | ||
| @@ -135,7 +135,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | log.Info(). | |
| 138 | - | Str("url", input.URL). | |
| 138 | + | Str("url", logging.RedactURL(input.URL)). | |
| 139 | 139 | Msg("navigation initiated") | |
| 140 | 140 | ||
| 141 | 141 | return &NavigateOutput{ | |
| @@ -309,7 +309,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 309 | 309 | // Check if entry exists | |
| 310 | 310 | existing, err := uc.historyRepo.FindByURL(ctx, record.url) | |
| 311 | 311 | if err != nil { | |
| 312 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to check history") | |
| 312 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history") | |
| 313 | 313 | return | |
| 314 | 314 | } | |
| 315 | 315 | ||
| @@ -317,7 +317,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 317 | 317 | delta := max(1, record.visits) | |
| 318 | 318 | if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok { | |
| 319 | 319 | if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil { | |
| 320 | - | log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta") | |
| 320 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta") | |
| 321 | 321 | return | |
| 322 | 322 | } | |
| 323 | 323 | return | |
| @@ -325,7 +325,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 325 | 325 | iterations := delta | |
| 326 | 326 | if iterations > historyFallbackIncrementCap { | |
| 327 | 327 | log.Warn(). | |
| 328 | - | Str("url", record.url). | |
| 328 | + | Str("url", logging.RedactURL(record.url)). | |
| 329 | 329 | Int("delta", delta). | |
| 330 | 330 | Int("cap", historyFallbackIncrementCap). | |
| 331 | 331 | Msg("visit count fallback increment capped") | |
| @@ -333,7 +333,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 333 | 333 | } | |
| 334 | 334 | for i := 0; i < iterations; i++ { | |
| 335 | 335 | if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil { | |
| 336 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count") | |
| 336 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count") | |
| 337 | 337 | return | |
| 338 | 338 | } | |
| 339 | 339 | } | |
| @@ -342,7 +342,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 342 | 342 | entry := entity.NewHistoryEntry(record.url, "") | |
| 343 | 343 | entry.VisitCount = int64(max(1, record.visits)) | |
| 344 | 344 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | |
| 345 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to save history") | |
| 345 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history") | |
| 346 | 346 | } | |
| 347 | 347 | } | |
| 348 | 348 | } | |
| @@ -358,7 +358,7 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t | |||
| 358 | 358 | ||
| 359 | 359 | entry, err := uc.historyRepo.FindByURL(ctx, historyURL) | |
| 360 | 360 | if err != nil { | |
| 361 | - | log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update") | |
| 361 | + | log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update") | |
| 362 | 362 | return | |
| 363 | 363 | } | |
| 364 | 364 | if entry == nil { | |
| @@ -368,12 +368,12 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t | |||
| 368 | 368 | entry.Title = title | |
| 369 | 369 | if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok { | |
| 370 | 370 | if err := updater.UpdateMetadata(ctx, entry); err != nil { | |
| 371 | - | log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata") | |
| 371 | + | log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata") | |
| 372 | 372 | } | |
| 373 | 373 | return | |
| 374 | 374 | } | |
| 375 | 375 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | |
| 376 | - | log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title") | |
| 376 | + | log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title") | |
| 377 | 377 | } | |
| 378 | 378 | } | |
| 379 | 379 | ||
| @@ -392,7 +392,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit | |||
| 392 | 392 | select { | |
| 393 | 393 | case uc.historyQueue <- historyRecord{url: historyURL, title: title}: | |
| 394 | 394 | default: | |
| 395 | - | logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped") | |
| 395 | + | logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped") | |
| 396 | 396 | } | |
| 397 | 397 | } | |
| 398 | 398 | ||
| @@ -124,7 +124,7 @@ type NavigateOutput struct { | |||||
| 124 | func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) { | 124 | func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) { | ||
| 125 | log := logging.FromContext(ctx) | 125 | log := logging.FromContext(ctx) | ||
| 126 | log.Debug(). | 126 | log.Debug(). | ||
| 127 | - | Str("url", input.URL). | 127 | + | Str("url", logging.RedactURL(input.URL)). |
| 128 | Str("pane_id", input.PaneID). | 128 | Str("pane_id", input.PaneID). | ||
| 129 | Msg("navigating to URL") | 129 | Msg("navigating to URL") | ||
| 130 | 130 | ||||
| @@ -135,7 +135,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | log.Info(). | 137 | log.Info(). | ||
| 138 | - | Str("url", input.URL). | 138 | + | Str("url", logging.RedactURL(input.URL)). |
| 139 | Msg("navigation initiated") | 139 | Msg("navigation initiated") | ||
| 140 | 140 | ||||
| 141 | return &NavigateOutput{ | 141 | return &NavigateOutput{ | ||
| @@ -309,7 +309,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 309 | // Check if entry exists | 309 | // Check if entry exists | ||
| 310 | existing, err := uc.historyRepo.FindByURL(ctx, record.url) | 310 | existing, err := uc.historyRepo.FindByURL(ctx, record.url) | ||
| 311 | if err != nil { | 311 | if err != nil { | ||
| 312 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to check history") | 312 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history") |
| 313 | return | 313 | return | ||
| 314 | } | 314 | } | ||
| 315 | 315 | ||||
| @@ -317,7 +317,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 317 | delta := max(1, record.visits) | 317 | delta := max(1, record.visits) | ||
| 318 | if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok { | 318 | if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok { | ||
| 319 | if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil { | 319 | if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil { | ||
| 320 | - | log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta") | 320 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta") |
| 321 | return | 321 | return | ||
| 322 | } | 322 | } | ||
| 323 | return | 323 | return | ||
| @@ -325,7 +325,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 325 | iterations := delta | 325 | iterations := delta | ||
| 326 | if iterations > historyFallbackIncrementCap { | 326 | if iterations > historyFallbackIncrementCap { | ||
| 327 | log.Warn(). | 327 | log.Warn(). | ||
| 328 | - | Str("url", record.url). | 328 | + | Str("url", logging.RedactURL(record.url)). |
| 329 | Int("delta", delta). | 329 | Int("delta", delta). | ||
| 330 | Int("cap", historyFallbackIncrementCap). | 330 | Int("cap", historyFallbackIncrementCap). | ||
| 331 | Msg("visit count fallback increment capped") | 331 | Msg("visit count fallback increment capped") | ||
| @@ -333,7 +333,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 333 | } | 333 | } | ||
| 334 | for i := 0; i < iterations; i++ { | 334 | for i := 0; i < iterations; i++ { | ||
| 335 | if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil { | 335 | if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil { | ||
| 336 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count") | 336 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count") |
| 337 | return | 337 | return | ||
| 338 | } | 338 | } | ||
| 339 | } | 339 | } | ||
| @@ -342,7 +342,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 342 | entry := entity.NewHistoryEntry(record.url, "") | 342 | entry := entity.NewHistoryEntry(record.url, "") | ||
| 343 | entry.VisitCount = int64(max(1, record.visits)) | 343 | entry.VisitCount = int64(max(1, record.visits)) | ||
| 344 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | 344 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | ||
| 345 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to save history") | 345 | + | log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history") |
| 346 | } | 346 | } | ||
| 347 | } | 347 | } | ||
| 348 | } | 348 | } | ||
| @@ -358,7 +358,7 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t | |||||
| 358 | 358 | ||||
| 359 | entry, err := uc.historyRepo.FindByURL(ctx, historyURL) | 359 | entry, err := uc.historyRepo.FindByURL(ctx, historyURL) | ||
| 360 | if err != nil { | 360 | if err != nil { | ||
| 361 | - | log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update") | 361 | + | log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update") |
| 362 | return | 362 | return | ||
| 363 | } | 363 | } | ||
| 364 | if entry == nil { | 364 | if entry == nil { | ||
| @@ -368,12 +368,12 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t | |||||
| 368 | entry.Title = title | 368 | entry.Title = title | ||
| 369 | if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok { | 369 | if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok { | ||
| 370 | if err := updater.UpdateMetadata(ctx, entry); err != nil { | 370 | if err := updater.UpdateMetadata(ctx, entry); err != nil { | ||
| 371 | - | log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata") | 371 | + | log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata") |
| 372 | } | 372 | } | ||
| 373 | return | 373 | return | ||
| 374 | } | 374 | } | ||
| 375 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | 375 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | ||
| 376 | - | log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title") | 376 | + | log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title") |
| 377 | } | 377 | } | ||
| 378 | } | 378 | } | ||
| 379 | 379 | ||||
| @@ -392,7 +392,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit | |||||
| 392 | select { | 392 | select { | ||
| 393 | case uc.historyQueue <- historyRecord{url: historyURL, title: title}: | 393 | case uc.historyQueue <- historyRecord{url: historyURL, title: title}: | ||
| 394 | default: | 394 | default: | ||
| 395 | - | logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped") | 395 | + | logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped") |
| 396 | } | 396 | } | ||
| 397 | } | 397 | } | ||
| 398 | 398 | ||||
M internal/logging/context.go +2 -2
| @@ -51,9 +51,9 @@ func WithTabID(ctx context.Context, tabID string) context.Context { | |||
| 51 | 51 | return WithContext(ctx, childLogger) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | // WithURL creates a child logger with a url field | |
| 54 | + | // WithURL creates a child logger with a redacted url field. | |
| 55 | 55 | func WithURL(ctx context.Context, url string) context.Context { | |
| 56 | 56 | logger := FromContext(ctx) | |
| 57 | - | childLogger := logger.With().Str("url", url).Logger() | |
| 57 | + | childLogger := logger.With().Str("url", RedactURL(url)).Logger() | |
| 58 | 58 | return WithContext(ctx, childLogger) | |
| 59 | 59 | } | |
| @@ -51,9 +51,9 @@ func WithTabID(ctx context.Context, tabID string) context.Context { | |||||
| 51 | return WithContext(ctx, childLogger) | 51 | return WithContext(ctx, childLogger) | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | // WithURL creates a child logger with a url field | 54 | + | // WithURL creates a child logger with a redacted url field. |
| 55 | func WithURL(ctx context.Context, url string) context.Context { | 55 | func WithURL(ctx context.Context, url string) context.Context { | ||
| 56 | logger := FromContext(ctx) | 56 | logger := FromContext(ctx) | ||
| 57 | - | childLogger := logger.With().Str("url", url).Logger() | 57 | + | childLogger := logger.With().Str("url", RedactURL(url)).Logger() |
| 58 | return WithContext(ctx, childLogger) | 58 | return WithContext(ctx, childLogger) | ||
| 59 | } | 59 | } | ||
M internal/logging/logger.go +49 -8
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | neturl "net/url" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -71,8 +72,8 @@ func New(cfg Config) zerolog.Logger { | |||
| 71 | 72 | // Returns the logger and a cleanup function to close the file. | |
| 72 | 73 | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | |
| 73 | 74 | const ( | |
| 74 | - | logDirPerm = 0o755 | |
| 75 | - | logFilePerm = 0o644 | |
| 75 | + | logDirPerm = 0o700 | |
| 76 | + | logFilePerm = 0o600 | |
| 76 | 77 | ) | |
| 77 | 78 | var writers []io.Writer | |
| 78 | 79 | cleanup := func() {} | |
| @@ -97,16 +98,24 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||
| 97 | 98 | ||
| 98 | 99 | // Session file output (JSON format for parsing) | |
| 99 | 100 | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | |
| 100 | - | // Ensure log directory exists (LogDir is from config, may not have logs subdir) | |
| 101 | + | // Ensure log directory exists (LogDir is from config, may not have logs subdir). | |
| 102 | + | // Session logs may contain browsing metadata, so keep them user-private. | |
| 101 | 103 | if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil { | |
| 102 | 104 | return zerolog.Logger{}, nil, err | |
| 103 | 105 | } | |
| 106 | + | if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil { | |
| 107 | + | return zerolog.Logger{}, nil, err | |
| 108 | + | } | |
| 104 | 109 | ||
| 105 | 110 | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | |
| 106 | 111 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm) | |
| 107 | 112 | if err != nil { | |
| 108 | 113 | return zerolog.Logger{}, nil, err | |
| 109 | 114 | } | |
| 115 | + | if err := file.Chmod(logFilePerm); err != nil { | |
| 116 | + | _ = file.Close() | |
| 117 | + | return zerolog.Logger{}, nil, err | |
| 118 | + | } | |
| 110 | 119 | ||
| 111 | 120 | sessionWriter := newSessionFileWriter(file) | |
| 112 | 121 | writers = append(writers, sessionWriter) | |
| @@ -242,12 +251,44 @@ func ParseLevel(level string) zerolog.Level { | |||
| 242 | 251 | // PermissionLogURLMaxLen is the standard max length for permission-related URL logging. | |
| 243 | 252 | const PermissionLogURLMaxLen = 96 | |
| 244 | 253 | ||
| 245 | - | // TruncateURL truncates a URL to maxLen characters for logging. | |
| 254 | + | // RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host. | |
| 255 | + | const RedactedURLPlaceholder = "[redacted-url]" | |
| 256 | + | ||
| 257 | + | // RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a | |
| 258 | + | // host are reduced to host-only; query strings, fragments, paths, credentials, | |
| 259 | + | // and local file paths are omitted. | |
| 260 | + | func RedactURL(raw string) string { | |
| 261 | + | raw = strings.TrimSpace(raw) | |
| 262 | + | if raw == "" { | |
| 263 | + | return "" | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | parsed, err := neturl.Parse(raw) | |
| 267 | + | if err != nil { | |
| 268 | + | return RedactedURLPlaceholder | |
| 269 | + | } | |
| 270 | + | if parsed.Host != "" { | |
| 271 | + | return parsed.Hostname() | |
| 272 | + | } | |
| 273 | + | if parsed.Scheme == "about" && parsed.Opaque != "" { | |
| 274 | + | // Use Opaque directly instead of parsed.String() to avoid leaking | |
| 275 | + | // RawQuery and Fragment (Go's URL parser separates them from Opaque, | |
| 276 | + | // but String() reconstructs the full URL including query/fragment). | |
| 277 | + | return parsed.Scheme + ":" + parsed.Opaque | |
| 278 | + | } | |
| 279 | + | if parsed.Scheme != "" { | |
| 280 | + | return parsed.Scheme + ":" + RedactedURLPlaceholder | |
| 281 | + | } | |
| 282 | + | return RedactedURLPlaceholder | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // TruncateURL redacts and then truncates a URL for logging. | |
| 246 | 286 | func TruncateURL(url string, maxLen int) string { | |
| 247 | - | if len(url) <= maxLen { | |
| 248 | - | return url | |
| 287 | + | redacted := RedactURL(url) | |
| 288 | + | if len(redacted) <= maxLen { | |
| 289 | + | return redacted | |
| 249 | 290 | } | |
| 250 | - | return url[:maxLen] + "..." | |
| 291 | + | return redacted[:maxLen] + "..." | |
| 251 | 292 | } | |
| 252 | 293 | ||
| 253 | 294 | // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics. | |
| @@ -256,5 +297,5 @@ func SafeURLHost(raw string) string { | |||
| 256 | 297 | if err != nil { | |
| 257 | 298 | return "" | |
| 258 | 299 | } | |
| 259 | - | return parsed.Host | |
| 300 | + | return parsed.Hostname() | |
| 260 | 301 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | neturl "net/url" | 6 | neturl "net/url" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| @@ -71,8 +72,8 @@ func New(cfg Config) zerolog.Logger { | |||||
| 71 | // Returns the logger and a cleanup function to close the file. | 72 | // Returns the logger and a cleanup function to close the file. | ||
| 72 | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | 73 | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | ||
| 73 | const ( | 74 | const ( | ||
| 74 | - | logDirPerm = 0o755 | 75 | + | logDirPerm = 0o700 |
| 75 | - | logFilePerm = 0o644 | 76 | + | logFilePerm = 0o600 |
| 76 | ) | 77 | ) | ||
| 77 | var writers []io.Writer | 78 | var writers []io.Writer | ||
| 78 | cleanup := func() {} | 79 | cleanup := func() {} | ||
| @@ -97,16 +98,24 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||||
| 97 | 98 | ||||
| 98 | // Session file output (JSON format for parsing) | 99 | // Session file output (JSON format for parsing) | ||
| 99 | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | 100 | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | ||
| 100 | - | // Ensure log directory exists (LogDir is from config, may not have logs subdir) | 101 | + | // Ensure log directory exists (LogDir is from config, may not have logs subdir). |
| 102 | + | // Session logs may contain browsing metadata, so keep them user-private. | |||
| 101 | if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil { | 103 | if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil { | ||
| 102 | return zerolog.Logger{}, nil, err | 104 | return zerolog.Logger{}, nil, err | ||
| 103 | } | 105 | } | ||
| 106 | + | if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil { | |||
| 107 | + | return zerolog.Logger{}, nil, err | |||
| 108 | + | } | |||
| 104 | 109 | ||||
| 105 | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | 110 | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | ||
| 106 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm) | 111 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm) | ||
| 107 | if err != nil { | 112 | if err != nil { | ||
| 108 | return zerolog.Logger{}, nil, err | 113 | return zerolog.Logger{}, nil, err | ||
| 109 | } | 114 | } | ||
| 115 | + | if err := file.Chmod(logFilePerm); err != nil { | |||
| 116 | + | _ = file.Close() | |||
| 117 | + | return zerolog.Logger{}, nil, err | |||
| 118 | + | } | |||
| 110 | 119 | ||||
| 111 | sessionWriter := newSessionFileWriter(file) | 120 | sessionWriter := newSessionFileWriter(file) | ||
| 112 | writers = append(writers, sessionWriter) | 121 | writers = append(writers, sessionWriter) | ||
| @@ -242,12 +251,44 @@ func ParseLevel(level string) zerolog.Level { | |||||
| 242 | // PermissionLogURLMaxLen is the standard max length for permission-related URL logging. | 251 | // PermissionLogURLMaxLen is the standard max length for permission-related URL logging. | ||
| 243 | const PermissionLogURLMaxLen = 96 | 252 | const PermissionLogURLMaxLen = 96 | ||
| 244 | 253 | ||||
| 245 | - | // TruncateURL truncates a URL to maxLen characters for logging. | 254 | + | // RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host. |
| 255 | + | const RedactedURLPlaceholder = "[redacted-url]" | |||
| 256 | + | ||||
| 257 | + | // RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a | |||
| 258 | + | // host are reduced to host-only; query strings, fragments, paths, credentials, | |||
| 259 | + | // and local file paths are omitted. | |||
| 260 | + | func RedactURL(raw string) string { | |||
| 261 | + | raw = strings.TrimSpace(raw) | |||
| 262 | + | if raw == "" { | |||
| 263 | + | return "" | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | parsed, err := neturl.Parse(raw) | |||
| 267 | + | if err != nil { | |||
| 268 | + | return RedactedURLPlaceholder | |||
| 269 | + | } | |||
| 270 | + | if parsed.Host != "" { | |||
| 271 | + | return parsed.Hostname() | |||
| 272 | + | } | |||
| 273 | + | if parsed.Scheme == "about" && parsed.Opaque != "" { | |||
| 274 | + | // Use Opaque directly instead of parsed.String() to avoid leaking | |||
| 275 | + | // RawQuery and Fragment (Go's URL parser separates them from Opaque, | |||
| 276 | + | // but String() reconstructs the full URL including query/fragment). | |||
| 277 | + | return parsed.Scheme + ":" + parsed.Opaque | |||
| 278 | + | } | |||
| 279 | + | if parsed.Scheme != "" { | |||
| 280 | + | return parsed.Scheme + ":" + RedactedURLPlaceholder | |||
| 281 | + | } | |||
| 282 | + | return RedactedURLPlaceholder | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // TruncateURL redacts and then truncates a URL for logging. | |||
| 246 | func TruncateURL(url string, maxLen int) string { | 286 | func TruncateURL(url string, maxLen int) string { | ||
| 247 | - | if len(url) <= maxLen { | 287 | + | redacted := RedactURL(url) |
| 248 | - | return url | 288 | + | if len(redacted) <= maxLen { |
| 289 | + | return redacted | |||
| 249 | } | 290 | } | ||
| 250 | - | return url[:maxLen] + "..." | 291 | + | return redacted[:maxLen] + "..." |
| 251 | } | 292 | } | ||
| 252 | 293 | ||||
| 253 | // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics. | 294 | // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics. | ||
| @@ -256,5 +297,5 @@ func SafeURLHost(raw string) string { | |||||
| 256 | if err != nil { | 297 | if err != nil { | ||
| 257 | return "" | 298 | return "" | ||
| 258 | } | 299 | } | ||
| 259 | - | return parsed.Host | 300 | + | return parsed.Hostname() |
| 260 | } | 301 | } | ||
M internal/logging/logger_test.go +105 -0
| @@ -3,9 +3,43 @@ package logging | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/rs/zerolog" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 12 | + | func TestRedactURLUsesHostOnlyForAbsoluteURLs(t *testing.T) { | |
| 13 | + | t.Parallel() | |
| 14 | + | ||
| 15 | + | got := RedactURL("https://user:pass@example.com/private/path?token=secret#frag") | |
| 16 | + | if got != "example.com" { | |
| 17 | + | t.Fatalf("RedactURL returned %q, want %q", got, "example.com") | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestRedactURLRedactsLocalOrOpaqueInputs(t *testing.T) { | |
| 22 | + | t.Parallel() | |
| 23 | + | ||
| 24 | + | for _, raw := range []string{"/home/user/private/file.html?token=secret", "not a url"} { | |
| 25 | + | if got := RedactURL(raw); got != RedactedURLPlaceholder { | |
| 26 | + | t.Fatalf("RedactURL(%q) returned %q, want %q", raw, got, RedactedURLPlaceholder) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestTruncateURLRedactsBeforeTruncating(t *testing.T) { | |
| 32 | + | t.Parallel() | |
| 33 | + | ||
| 34 | + | got := TruncateURL("https://example.com/private/path?token=secret#frag", 96) | |
| 35 | + | if got != "example.com" { | |
| 36 | + | t.Fatalf("TruncateURL returned %q, want %q", got, "example.com") | |
| 37 | + | } | |
| 38 | + | if strings.Contains(got, "token") || strings.Contains(got, "/private") || strings.Contains(got, "frag") { | |
| 39 | + | t.Fatalf("TruncateURL leaked sensitive URL data: %q", got) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 9 | 43 | func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) { | |
| 10 | 44 | t.Parallel() | |
| 11 | 45 | ||
| @@ -25,6 +59,77 @@ func TestSafeURLHostReturnsEmptyForOpaqueOrInvalidInput(t *testing.T) { | |||
| 25 | 59 | } | |
| 26 | 60 | } | |
| 27 | 61 | ||
| 62 | + | func TestRedactURLStripsQueryFragmentFromAboutOpaqueURLs(t *testing.T) { | |
| 63 | + | t.Parallel() | |
| 64 | + | ||
| 65 | + | got := RedactURL("about:blank?token=secret#frag") | |
| 66 | + | if want := "about:blank"; got != want { | |
| 67 | + | t.Fatalf("RedactURL(about:blank?token=secret#frag) = %q, want %q", got, want) | |
| 68 | + | } | |
| 69 | + | if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") { | |
| 70 | + | t.Fatalf("RedactURL leaked query/fragment from about: opaque URL: %q", got) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestRedactURLRedactsFileURLPathQueryFragment(t *testing.T) { | |
| 75 | + | t.Parallel() | |
| 76 | + | ||
| 77 | + | got := RedactURL("file:///home/user/private?token=secret#frag") | |
| 78 | + | if got != "file:"+RedactedURLPlaceholder { | |
| 79 | + | t.Fatalf("RedactURL(file:///home/user/private?token=secret#frag) = %q, want %q", got, "file:"+RedactedURLPlaceholder) | |
| 80 | + | } | |
| 81 | + | if strings.Contains(got, "home") || strings.Contains(got, "user") || strings.Contains(got, "private") { | |
| 82 | + | t.Fatalf("RedactURL leaked file path from file:// URL: %q", got) | |
| 83 | + | } | |
| 84 | + | if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") { | |
| 85 | + | t.Fatalf("RedactURL leaked query/fragment from file:// URL: %q", got) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestRedactURLReturnsPlaceholderForInvalidParse(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 91 | + | ||
| 92 | + | got := RedactURL("http://[::1") | |
| 93 | + | if got != RedactedURLPlaceholder { | |
| 94 | + | t.Fatalf("RedactURL(http://[::1) = %q, want %q", got, RedactedURLPlaceholder) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestNewWithFileCreatesUserPrivateLogDirectoryAndFile(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 100 | + | ||
| 101 | + | parent := t.TempDir() | |
| 102 | + | logDir := filepath.Join(parent, "logs") | |
| 103 | + | logger, cleanup, err := NewWithFile(Config{Level: zerolog.InfoLevel, Format: logFormatJSON}, FileConfig{ | |
| 104 | + | Enabled: true, | |
| 105 | + | LogDir: logDir, | |
| 106 | + | SessionID: "test-session", | |
| 107 | + | }) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatalf("NewWithFile: %v", err) | |
| 110 | + | } | |
| 111 | + | defer cleanup() | |
| 112 | + | ||
| 113 | + | logger.Info().Msg("hello") | |
| 114 | + | cleanup() | |
| 115 | + | ||
| 116 | + | dirInfo, err := os.Stat(logDir) | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatalf("stat log dir: %v", err) | |
| 119 | + | } | |
| 120 | + | if got := dirInfo.Mode().Perm(); got != 0o700 { | |
| 121 | + | t.Fatalf("log dir permissions = %o, want 700", got) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | fileInfo, err := os.Stat(filepath.Join(logDir, SessionFilename("test-session"))) | |
| 125 | + | if err != nil { | |
| 126 | + | t.Fatalf("stat log file: %v", err) | |
| 127 | + | } | |
| 128 | + | if got := fileInfo.Mode().Perm(); got != 0o600 { | |
| 129 | + | t.Fatalf("log file permissions = %o, want 600", got) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 28 | 133 | func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | |
| 29 | 134 | t.Parallel() | |
| 30 | 135 | ||
| @@ -3,9 +3,43 @@ package logging | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | ||||
| 9 | + | "github.com/rs/zerolog" | |||
| 7 | ) | 10 | ) | ||
| 8 | 11 | ||||
| 12 | + | func TestRedactURLUsesHostOnlyForAbsoluteURLs(t *testing.T) { | |||
| 13 | + | t.Parallel() | |||
| 14 | + | ||||
| 15 | + | got := RedactURL("https://user:pass@example.com/private/path?token=secret#frag") | |||
| 16 | + | if got != "example.com" { | |||
| 17 | + | t.Fatalf("RedactURL returned %q, want %q", got, "example.com") | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestRedactURLRedactsLocalOrOpaqueInputs(t *testing.T) { | |||
| 22 | + | t.Parallel() | |||
| 23 | + | ||||
| 24 | + | for _, raw := range []string{"/home/user/private/file.html?token=secret", "not a url"} { | |||
| 25 | + | if got := RedactURL(raw); got != RedactedURLPlaceholder { | |||
| 26 | + | t.Fatalf("RedactURL(%q) returned %q, want %q", raw, got, RedactedURLPlaceholder) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestTruncateURLRedactsBeforeTruncating(t *testing.T) { | |||
| 32 | + | t.Parallel() | |||
| 33 | + | ||||
| 34 | + | got := TruncateURL("https://example.com/private/path?token=secret#frag", 96) | |||
| 35 | + | if got != "example.com" { | |||
| 36 | + | t.Fatalf("TruncateURL returned %q, want %q", got, "example.com") | |||
| 37 | + | } | |||
| 38 | + | if strings.Contains(got, "token") || strings.Contains(got, "/private") || strings.Contains(got, "frag") { | |||
| 39 | + | t.Fatalf("TruncateURL leaked sensitive URL data: %q", got) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 9 | func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) { | 43 | func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) { | ||
| 10 | t.Parallel() | 44 | t.Parallel() | ||
| 11 | 45 | ||||
| @@ -25,6 +59,77 @@ func TestSafeURLHostReturnsEmptyForOpaqueOrInvalidInput(t *testing.T) { | |||||
| 25 | } | 59 | } | ||
| 26 | } | 60 | } | ||
| 27 | 61 | ||||
| 62 | + | func TestRedactURLStripsQueryFragmentFromAboutOpaqueURLs(t *testing.T) { | |||
| 63 | + | t.Parallel() | |||
| 64 | + | ||||
| 65 | + | got := RedactURL("about:blank?token=secret#frag") | |||
| 66 | + | if want := "about:blank"; got != want { | |||
| 67 | + | t.Fatalf("RedactURL(about:blank?token=secret#frag) = %q, want %q", got, want) | |||
| 68 | + | } | |||
| 69 | + | if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") { | |||
| 70 | + | t.Fatalf("RedactURL leaked query/fragment from about: opaque URL: %q", got) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRedactURLRedactsFileURLPathQueryFragment(t *testing.T) { | |||
| 75 | + | t.Parallel() | |||
| 76 | + | ||||
| 77 | + | got := RedactURL("file:///home/user/private?token=secret#frag") | |||
| 78 | + | if got != "file:"+RedactedURLPlaceholder { | |||
| 79 | + | t.Fatalf("RedactURL(file:///home/user/private?token=secret#frag) = %q, want %q", got, "file:"+RedactedURLPlaceholder) | |||
| 80 | + | } | |||
| 81 | + | if strings.Contains(got, "home") || strings.Contains(got, "user") || strings.Contains(got, "private") { | |||
| 82 | + | t.Fatalf("RedactURL leaked file path from file:// URL: %q", got) | |||
| 83 | + | } | |||
| 84 | + | if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") { | |||
| 85 | + | t.Fatalf("RedactURL leaked query/fragment from file:// URL: %q", got) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestRedactURLReturnsPlaceholderForInvalidParse(t *testing.T) { | |||
| 90 | + | t.Parallel() | |||
| 91 | + | ||||
| 92 | + | got := RedactURL("http://[::1") | |||
| 93 | + | if got != RedactedURLPlaceholder { | |||
| 94 | + | t.Fatalf("RedactURL(http://[::1) = %q, want %q", got, RedactedURLPlaceholder) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestNewWithFileCreatesUserPrivateLogDirectoryAndFile(t *testing.T) { | |||
| 99 | + | t.Parallel() | |||
| 100 | + | ||||
| 101 | + | parent := t.TempDir() | |||
| 102 | + | logDir := filepath.Join(parent, "logs") | |||
| 103 | + | logger, cleanup, err := NewWithFile(Config{Level: zerolog.InfoLevel, Format: logFormatJSON}, FileConfig{ | |||
| 104 | + | Enabled: true, | |||
| 105 | + | LogDir: logDir, | |||
| 106 | + | SessionID: "test-session", | |||
| 107 | + | }) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatalf("NewWithFile: %v", err) | |||
| 110 | + | } | |||
| 111 | + | defer cleanup() | |||
| 112 | + | ||||
| 113 | + | logger.Info().Msg("hello") | |||
| 114 | + | cleanup() | |||
| 115 | + | ||||
| 116 | + | dirInfo, err := os.Stat(logDir) | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatalf("stat log dir: %v", err) | |||
| 119 | + | } | |||
| 120 | + | if got := dirInfo.Mode().Perm(); got != 0o700 { | |||
| 121 | + | t.Fatalf("log dir permissions = %o, want 700", got) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | fileInfo, err := os.Stat(filepath.Join(logDir, SessionFilename("test-session"))) | |||
| 125 | + | if err != nil { | |||
| 126 | + | t.Fatalf("stat log file: %v", err) | |||
| 127 | + | } | |||
| 128 | + | if got := fileInfo.Mode().Perm(); got != 0o600 { | |||
| 129 | + | t.Fatalf("log file permissions = %o, want 600", got) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 28 | func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | 133 | func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | ||
| 29 | t.Parallel() | 134 | t.Parallel() | ||
| 30 | 135 | ||||