package server import ( "net/http" "net/url" "strings" "uncensored-send/internal/config" ) // loginPage backs both the form and its error redisplay. type loginPage struct { page Error string Next string // Limits describe what the presented credential would be allowed to do, // shown once logged in so the upload page does not have to guess. MaxSize string MaxExpiry string Vanity bool } // loginDestinations is the allowlist for the post-login redirect. Restricting // it to known page names means the parameter can never name somewhere else. var loginDestinations = map[string]string{ "": "", "admin": "admin", } func destination(next string) string { page, ok := loginDestinations[next] if !ok { return "" } return page } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { next := destination(r.URL.Query().Get("next")) // Already logged in: say so rather than showing an empty form. if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil && !lim.Anonymous() { s.render(w, http.StatusOK, "login.html", loginPage{ page: s.page(r, "Log in", false), Next: next, MaxSize: config.FormatSize(lim.MaxSize), MaxExpiry: config.FormatDuration(lim.MaxExpiry), Vanity: lim.AllowVanity, }) return } s.render(w, http.StatusOK, "login.html", loginPage{ page: s.page(r, "Log in", false), Next: next, }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes) if err := r.ParseForm(); err != nil { s.fail(w, r, http.StatusBadRequest, "Malformed form submission.") return } token := strings.TrimSpace(r.PostFormValue("token")) next := destination(r.PostFormValue("next")) if token == "" { s.loginFailed(w, r, next, http.StatusBadRequest, "Enter a token.") return } // Only failures are throttled, so logging in normally is never delayed. lim, err := s.limitsFor(r, token) if err != nil { if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) { s.loginFailed(w, r, next, http.StatusTooManyRequests, "Too many failed attempts; try again shortly.") return } s.log.Info("failed login", "ip", clientIP(r, s.cfg)) s.loginFailed(w, r, next, http.StatusUnauthorized, "That token is not recognised.") return } s.logIn(w, r, token, r.PostFormValue("persist") != "") s.log.Info("logged in", "name", lim.Name, "ip", clientIP(r, s.cfg)) if wantsJSON(r) { writeJSON(w, http.StatusOK, map[string]string{"status": "logged in", "name": lim.Name}) return } http.Redirect(w, r, s.cfg.BasePath+next, http.StatusSeeOther) } func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, next string, status int, msg string) { if wantsJSON(r) { s.fail(w, r, status, msg) return } s.render(w, status, "login.html", loginPage{ page: s.page(r, "Log in", false), Error: msg, Next: next, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { s.forget(w, r) if wantsJSON(r) { writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) return } http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther) } // sameOrigin guards the state-changing routes against cross-site form posts. // // The cookie is SameSite=Strict, which already stops another site from acting // as a logged-in user. This covers the case that does not need a cookie at all: // a hostile page posting to /login to sign a visitor into an account the // attacker controls, so that the visitor's uploads land under it. // // Browsers label their own requests; API clients send neither header, and their // bearer tokens are not attachable by a third party anyway. So an absent label // is allowed and a present one must say same-origin. func sameOrigin(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "same-origin", "none": return true case "": // older browser, or not a browser at all; fall through to Origin default: return false } origin := r.Header.Get("Origin") if origin == "" || origin == "null" { return origin == "" } u, err := url.Parse(origin) if err != nil { return false } return u.Host == r.Host } func (s *Server) requireSameOrigin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && !sameOrigin(r) { s.log.Info("rejected cross-origin post", "path", r.URL.Path, "origin", r.Header.Get("Origin"), "ip", clientIP(r, s.cfg)) s.fail(w, r, http.StatusForbidden, "Cross-site form submissions are not accepted.") return } next.ServeHTTP(w, r) }) }