validateReturnToPath enforces the same-origin / known-prefix allow-list for return_to values. Accepting an arbitrary URL would turn /api/auth/login into an open redirector that an attacker could chain with phishing-class social engineering. Limiting to /oauth2/-prefixed server paths means the bounce
(raw string)
| 305 | // the bounce can only land inside the OAuth flow we own (Slice 5b renamed the |
| 306 | // OAuth surface from /oauth2/* to /oauth2/*). |
| 307 | func validateReturnToPath(raw string) error { |
| 308 | if !strings.HasPrefix(raw, "/oauth2/") { |
| 309 | return errors.New("return_to must be a server path starting with /oauth2/") |
| 310 | } |
| 311 | if strings.ContainsAny(raw, "\\\n\r\x00") { |
| 312 | return errors.New("return_to contains forbidden characters") |
| 313 | } |
| 314 | // url.Parse should produce a clean path-only URL: no scheme, no host. |
| 315 | u, err := url.Parse(raw) |
| 316 | if err != nil { |
| 317 | return errors.New("return_to is not a valid URL path") |
| 318 | } |
| 319 | if u.Scheme != "" || u.Host != "" || u.User != nil { |
| 320 | return errors.New("return_to must be a path with no scheme or authority") |
| 321 | } |
| 322 | // Reject path traversal that survives the HasPrefix check by being |
| 323 | // collapsed by the browser. e.g. raw "/oauth2/../../dashboard" |
| 324 | // matches the prefix but http.Redirect emits a Location header that |
| 325 | // the browser resolves to "/dashboard" — escaping the allow-list. |
| 326 | // path.Clean folds the "../" segments and we re-check the prefix on |
| 327 | // the normalized form. |
| 328 | cleaned := path.Clean(u.Path) |
| 329 | if !strings.HasPrefix(cleaned, "/oauth2/") && cleaned != "/oauth2" { |
| 330 | return errors.New("return_to escapes the allow-list after normalization") |
| 331 | } |
| 332 | // Also reject empty segments which a future router refactor might |
| 333 | // treat as authority. "/oauth2//foo" survives path.Clean as |
| 334 | // "/oauth2/foo" but the raw value carries the empty segment |
| 335 | // which some HTTP stacks parse differently — fail closed. |
| 336 | if strings.Contains(u.Path, "//") { |
| 337 | return errors.New("return_to must not contain empty path segments") |
| 338 | } |
| 339 | return nil |
| 340 | } |
| 341 | |
| 342 | // HandleCallback processes the Google OAuth callback and creates a session. |
| 343 | func (ua *UserAuth) HandleCallback(w http.ResponseWriter, r *http.Request) { |
no outgoing calls
no test coverage detected