| 1 | package console |
| 2 | |
| 3 | // Passkey step-up for sensitive actions (spec §7a). |
| 4 | // |
| 5 | // Membership answers *who you are*. It does not answer *are you here now* — and |
| 6 | // for the handful of actions where that is the whole question (approving a |
| 7 | // `carlos auth login` device grant, removing a member, transferring ownership, |
| 8 | // deleting an account, revoking CLI tokens) a session cookie lifted from a |
| 9 | // laptop is currently indistinguishable from the person it belongs to. |
| 10 | // |
| 11 | // The route is a FRESH Keymail authentication, not console-native WebAuthn: |
| 12 | // identity belongs in one place in this platform, and a console holding its own |
| 13 | // credentials would be a second identity system with a second attack surface. |
| 14 | // Keymail already asserts with `UserVerification: VerificationRequired`; what it |
| 15 | // gained for this is `prompt=login` and an `auth_time` claim (keymail |
| 16 | // docs/apps.md), so the console can DEMAND a gesture and then VERIFY one |
| 17 | // happened rather than trusting the redirect it got back. |
| 18 | // |
| 19 | // Three properties this file exists to hold, in order of how easily they are |
| 20 | // lost: |
| 21 | // |
| 22 | // 1. UNSKIPPABLE SERVER-SIDE. The gate is not a UI affordance. requireFreshAuth |
| 23 | // reads database state written only by a verified callback; a direct POST |
| 24 | // with a valid session and a valid CSRF token is refused all the same. |
| 25 | // |
| 26 | // 2. THE PENDING ACTION IS NOT A CAPABILITY. The action is preserved across the |
| 27 | // round trip so a human is not dumped back at square one, but it is stored |
| 28 | // SERVER-SIDE, bound to the session that stashed it, single-use (burned by |
| 29 | // row deletion) and short-lived. The browser carries an opaque token in an |
| 30 | // HttpOnly cookie and nothing else — it cannot swap the action, replay it, |
| 31 | // or hand it to another session. |
| 32 | // |
| 33 | // 3. NO ORACLE. Step-up runs AFTER the membership/ownership gate, never before. |
| 34 | // A non-member probing an owner-only action must still get the |
| 35 | // byte-identical http.NotFound, not a redirect to Keymail that would confirm |
| 36 | // the account exists. |
| 37 | // |
| 38 | // The CSRF check stays exactly where it was. Step-up is in addition to it: CSRF |
| 39 | // answers "did this browser mean to send this?", step-up answers "is the human |
| 40 | // still here?", and neither substitutes for the other. |
| 41 | |
| 42 | import ( |
| 43 | "context" |
| 44 | "crypto/subtle" |
| 45 | "database/sql" |
| 46 | "log" |
| 47 | "net/http" |
| 48 | "net/url" |
| 49 | "strings" |
| 50 | "time" |
| 51 | ) |
| 52 | |
| 53 | const ( |
| 54 | // StepUpWindow is how recent the authenticator gesture must be for a |
| 55 | // sensitive action to proceed. |
| 56 | // |
| 57 | // Five minutes. Shorter is not obviously safer here and is definitely |
| 58 | // worse: the gated flows are multi-step (approving a device grant means |
| 59 | // typing a code, reading the terminal's description, then deciding), and a |
| 60 | // window that expires mid-decision trains people to click through |
| 61 | // authenticator prompts without reading them. Longer starts to defeat the |
| 62 | // point — the threat is a session stolen and used soon after, and every |
| 63 | // extra minute is a minute of that theft that lands inside the window. |
| 64 | // Five minutes also covers the round trip itself (redirect, consent card, |
| 65 | // a slow authenticator) with room to spare, so the common case is one |
| 66 | // touch, not two. |
| 67 | // |
| 68 | // It is measured from the GESTURE (Keymail's auth_time), not from when the |
| 69 | // console heard about it — the freshness that matters is the human's, and |
| 70 | // timing the console's own bookkeeping would let a slow network buy time. |
| 71 | StepUpWindow = 5 * time.Minute |
| 72 | |
| 73 | // stepUpFlowTTL bounds the round trip: mint state, go to Keymail, come |
| 74 | // back. Ten minutes is the same order as the sign-in flow's own cookie. |
| 75 | stepUpFlowTTL = 10 * time.Minute |
| 76 | |
| 77 | // stepUpSkew tolerates a small clock difference between this console and |
| 78 | // the Keymail deployment. Deliberately tiny — this is skew tolerance, not |
| 79 | // a second freshness window. |
| 80 | stepUpSkew = 60 * time.Second |
| 81 | |
| 82 | // stepUpCookie carries state + PKCE verifier + the pending-action token |
| 83 | // across the round trip. HttpOnly, like every other cookie here, and |
| 84 | // never a query parameter: a URL ends up in history, referrers and logs, |
| 85 | // and the pending-action token must not. |
| 86 | stepUpCookie = "carlos_console_stepup" |
| 87 | ) |
| 88 | |
| 89 | // stepUpUnavailable is what a human sees when the identity provider cannot |
| 90 | // re-authenticate them, or cannot be reached to try. Failing CLOSED is the |
| 91 | // whole design: the alternative — quietly proceeding on session-only |
| 92 | // authority — is precisely the hole this feature exists to close, and it would |
| 93 | // close it only on the days Keymail happens to be up. |
| 94 | const stepUpUnavailable = "This action needs you to confirm it's really you with your passkey, " + |
| 95 | "and Keymail can't be asked right now. Nothing has been changed. Try again in a moment — " + |
| 96 | "if this keeps happening, this deployment's Keymail does not support re-authentication " + |
| 97 | "and these actions stay blocked until it does." |
| 98 | |
| 99 | // stepUpHoldFailed is the message when the console cannot hold on to the |
| 100 | // action, or (an email session) cannot send the confirmation for it. |
| 101 | const stepUpHoldFailed = "Could not hold on to that action. Please try again." |
| 102 | |
| 103 | // ---- storage ---------------------------------------------------------------- |
| 104 | |
| 105 | // migrateStepUp adds this feature's two tables. Additive and idempotent, and |
| 106 | // kept here rather than in store.go's list so the feature is one file to read |
| 107 | // and one file to remove (the authstore.go precedent). |
| 108 | func (s *Store) migrateStepUp() error { |
| 109 | for _, stmt := range []string{ |
| 110 | // One row per session that has stepped up. Keyed by the session hash, |
| 111 | // so it dies with the session and cannot be moved to another one. |
| 112 | // address is stored alongside for a belt-and-braces check: a session |
| 113 | // row could in principle be re-pointed, and a step-up recorded for one |
| 114 | // identity must never satisfy another. |
| 115 | `CREATE TABLE IF NOT EXISTS step_ups ( |
| 116 | session_hash TEXT PRIMARY KEY, |
| 117 | address TEXT NOT NULL, |
| 118 | auth_time INTEGER NOT NULL, |
| 119 | recorded_at INTEGER NOT NULL |
| 120 | );`, |
| 121 | // The action a human was part-way through when the gate stopped them. |
| 122 | // Stored server-side precisely so it cannot be edited in flight: the |
| 123 | // browser holds an opaque token, never the method, path or fields. |
| 124 | `CREATE TABLE IF NOT EXISTS pending_actions ( |
| 125 | token_hash TEXT PRIMARY KEY, |
| 126 | session_hash TEXT NOT NULL, |
| 127 | address TEXT NOT NULL, |
| 128 | method TEXT NOT NULL, |
| 129 | path TEXT NOT NULL, |
| 130 | form TEXT NOT NULL, |
| 131 | created_at INTEGER NOT NULL, |
| 132 | expires_at INTEGER NOT NULL |
| 133 | );`, |
| 134 | } { |
| 135 | if _, err := s.db.Exec(stmt); err != nil { |
| 136 | return err |
| 137 | } |
| 138 | } |
| 139 | return nil |
| 140 | } |
| 141 | |
| 142 | // RecordStepUp remembers that this session's owner proved presence at |
| 143 | // authTime. Upsert: a newer gesture replaces an older one. |
| 144 | func (s *Store) RecordStepUp(rawSession, address string, authTime, now time.Time) error { |
| 145 | _, err := s.db.Exec(`INSERT INTO step_ups (session_hash, address, auth_time, recorded_at) |
| 146 | VALUES (?, ?, ?, ?) |
| 147 | ON CONFLICT(session_hash) DO UPDATE SET |
| 148 | address = excluded.address, auth_time = excluded.auth_time, recorded_at = excluded.recorded_at`, |
| 149 | hashToken(rawSession), address, authTime.Unix(), now.Unix()) |
| 150 | return err |
| 151 | } |
| 152 | |
| 153 | // FreshStepUp is the gate's one question. Both the session AND the address must |
| 154 | // match, and the gesture must be inside the window; anything else — no row, a |
| 155 | // stale row, a row belonging to another identity — is false. |
| 156 | func (s *Store) FreshStepUp(rawSession, address string, now time.Time, window time.Duration) bool { |
| 157 | var storedAddr string |
| 158 | var authTime int64 |
| 159 | err := s.db.QueryRow(`SELECT address, auth_time FROM step_ups WHERE session_hash = ?`, |
| 160 | hashToken(rawSession)).Scan(&storedAddr, &authTime) |
| 161 | if err != nil || storedAddr != address { |
| 162 | return false |
| 163 | } |
| 164 | age := now.Sub(time.Unix(authTime, 0)) |
| 165 | return age >= -stepUpSkew && age <= window |
| 166 | } |
| 167 | |
| 168 | // ClearStepUp drops a session's proof of presence. Called on sign-out: the |
| 169 | // session is gone, and so is any authority it had accrued. |
| 170 | func (s *Store) ClearStepUp(rawSession string) error { |
| 171 | _, err := s.db.Exec(`DELETE FROM step_ups WHERE session_hash = ?`, hashToken(rawSession)) |
| 172 | return err |
| 173 | } |
| 174 | |
| 175 | // pendingAction is a POST held mid-flight. |
| 176 | type pendingAction struct { |
| 177 | Method string |
| 178 | Path string |
| 179 | Form string |
| 180 | } |
| 181 | |
| 182 | // StashPendingAction parks the request the gate interrupted and returns the |
| 183 | // opaque token that redeems it. Housekeeping on the write path: expired stashes |
| 184 | // have nothing left to resume. |
| 185 | func (s *Store) StashPendingAction(rawSession, address, method, path, form string, now time.Time) (string, error) { |
| 186 | s.db.Exec(`DELETE FROM pending_actions WHERE expires_at < ?`, now.Unix()) |
| 187 | raw, err := NewVerifier() |
| 188 | if err != nil { |
| 189 | return "", err |
| 190 | } |
| 191 | _, err = s.db.Exec(`INSERT INTO pending_actions |
| 192 | (token_hash, session_hash, address, method, path, form, created_at, expires_at) |
| 193 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, |
| 194 | hashToken(raw), hashToken(rawSession), address, method, path, form, |
| 195 | now.Unix(), now.Add(stepUpFlowTTL).Unix()) |
| 196 | if err != nil { |
| 197 | return "", err |
| 198 | } |
| 199 | return raw, nil |
| 200 | } |
| 201 | |
| 202 | // TakePendingAction redeems a stash exactly once, and only for the session that |
| 203 | // created it. Burn-then-check, like the OAuth code and the confirmation link: the |
| 204 | // row is deleted before any verdict is returned, so a wrong-session or expired |
| 205 | // attempt cannot be retried into a right one. |
| 206 | func (s *Store) TakePendingAction(rawToken, rawSession string, now time.Time) (*pendingAction, bool) { |
| 207 | if rawToken == "" { |
| 208 | return nil, false |
| 209 | } |
| 210 | return s.takePendingActionByHash(hashToken(rawToken), rawSession, now) |
| 211 | } |
| 212 | |
| 213 | // takePendingActionByHash is TakePendingAction for a caller that holds only |
| 214 | // the token's hash (an email step-up challenge stores the hash, never the |
| 215 | // token): the same transaction, single-use delete, session binding and expiry |
| 216 | // check. |
| 217 | func (s *Store) takePendingActionByHash(hash, rawSession string, now time.Time) (*pendingAction, bool) { |
| 218 | if hash == "" { |
| 219 | return nil, false |
| 220 | } |
| 221 | tx, err := s.db.Begin() |
| 222 | if err != nil { |
| 223 | return nil, false |
| 224 | } |
| 225 | defer tx.Rollback() |
| 226 | var sessionHash string |
| 227 | var expires int64 |
| 228 | var p pendingAction |
| 229 | err = tx.QueryRow(`SELECT session_hash, method, path, form, expires_at |
| 230 | FROM pending_actions WHERE token_hash = ?`, hash). |
| 231 | Scan(&sessionHash, &p.Method, &p.Path, &p.Form, &expires) |
| 232 | if err == sql.ErrNoRows { |
| 233 | return nil, false |
| 234 | } |
| 235 | if err != nil { |
| 236 | return nil, false |
| 237 | } |
| 238 | if _, err := tx.Exec(`DELETE FROM pending_actions WHERE token_hash = ?`, hash); err != nil { |
| 239 | return nil, false |
| 240 | } |
| 241 | if err := tx.Commit(); err != nil { |
| 242 | return nil, false |
| 243 | } |
| 244 | if subtle.ConstantTimeCompare([]byte(sessionHash), []byte(hashToken(rawSession))) != 1 { |
| 245 | return nil, false |
| 246 | } |
| 247 | if now.Unix() > expires { |
| 248 | return nil, false |
| 249 | } |
| 250 | return &p, true |
| 251 | } |
| 252 | |
| 253 | // ---- the gate --------------------------------------------------------------- |
| 254 | |
| 255 | // requireFreshAuth is the step-up gate. It returns true only when this session |
| 256 | // has proved presence inside StepUpWindow; otherwise it has ALREADY written the |
| 257 | // response (a redirect into Keymail's re-authentication, for an email session |
| 258 | // the "Confirm it's you" page of an emailed link and code, or a fail-closed |
| 259 | // message) and the caller must simply return. |
| 260 | // |
| 261 | // Call it LAST — after the membership/ownership gate and after the CSRF check. |
| 262 | // Order is load-bearing twice over: a non-member must get the byte-identical |
| 263 | // 404 rather than a redirect that confirms the account exists, and a |
| 264 | // cross-site POST must be refused before it can park a pending action. |
| 265 | func (s *Server) requireFreshAuth(w http.ResponseWriter, r *http.Request, addr, raw string) bool { |
| 266 | now := time.Now() |
| 267 | if s.store.FreshStepUp(raw, addr, now, StepUpWindow) { |
| 268 | return true |
| 269 | } |
| 270 | // A session that signed in by email re-confirms by email (spec 6). Only |
| 271 | // that method does: a Keymail session, or one whose method cannot be |
| 272 | // read, takes the passkey path below and never an email instead. |
| 273 | if _, method, ok := s.store.SessionInfo(raw, now); ok && method == MethodEmail { |
| 274 | s.emailStepUp(w, r, addr, raw, now) |
| 275 | return false |
| 276 | } |
| 277 | // Ask the identity provider whether it can do this at all before sending a |
| 278 | // human off to try. Unreachable and "no" are the same answer here — see |
| 279 | // stepUpUnavailable. |
| 280 | ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| 281 | defer cancel() |
| 282 | switch supported, err := s.km.SupportsStepUp(ctx); { |
| 283 | case err != nil: |
| 284 | log.Printf("console: step-up capability check: %v", err) |
| 285 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Can't confirm it's you", stepUpUnavailable) |
| 286 | return false |
| 287 | case !supported: |
| 288 | log.Printf("console: step-up refused: %s does not advertise re-authentication", s.cfg.Keymail) |
| 289 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Can't confirm it's you", stepUpUnavailable) |
| 290 | return false |
| 291 | } |
| 292 | state, err := NewVerifier() |
| 293 | if err != nil { |
| 294 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.") |
| 295 | return false |
| 296 | } |
| 297 | verifier, err := NewVerifier() |
| 298 | if err != nil { |
| 299 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.") |
| 300 | return false |
| 301 | } |
| 302 | pending, ok := s.stashRequest(w, r, addr, raw, now) |
| 303 | if !ok { |
| 304 | return false |
| 305 | } |
| 306 | s.setCookie(w, stepUpCookie, state+"."+verifier+"."+pending, int(stepUpFlowTTL.Seconds())) |
| 307 | http.Redirect(w, r, s.km.StepUpURL(state, ChallengeS256(verifier), StepUpWindow), http.StatusSeeOther) |
| 308 | return false |
| 309 | } |
| 310 | |
| 311 | // stashRequest parks the request the gate interrupted (StashPendingAction) |
| 312 | // and returns the token that redeems it. On false it has already written the |
| 313 | // response. |
| 314 | func (s *Server) stashRequest(w http.ResponseWriter, r *http.Request, addr, raw string, now time.Time) (string, bool) { |
| 315 | // The handler has already read fields off this request, so PostForm is |
| 316 | // parsed and cached; Encode gives back exactly what it will be re-fed. |
| 317 | if err := r.ParseForm(); err != nil { |
| 318 | s.renderMessage(w, r, http.StatusBadRequest, "Refused", "That request could not be read.") |
| 319 | return "", false |
| 320 | } |
| 321 | // The csrf field is deliberately NOT stored. It is the raw session token, |
| 322 | // and sessions are kept hashed here precisely so a database read is not a |
| 323 | // session; parking a live one in a form blob would quietly undo that. The |
| 324 | // resume re-injects it from the cookie the resuming request carries — which |
| 325 | // costs nothing in safety, because the cross-site question was already |
| 326 | // answered before this stash was ever created. |
| 327 | form := url.Values{} |
| 328 | for k, v := range r.PostForm { |
| 329 | if k != "csrf" { |
| 330 | form[k] = v |
| 331 | } |
| 332 | } |
| 333 | pending, err := s.store.StashPendingAction(raw, addr, r.Method, r.URL.Path, form.Encode(), now) |
| 334 | if err != nil { |
| 335 | log.Printf("console: stash pending action: %v", err) |
| 336 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", stepUpHoldFailed) |
| 337 | return "", false |
| 338 | } |
| 339 | return pending, true |
| 340 | } |
| 341 | |
| 342 | // handleStepUpCallback is where a re-authentication lands. It trusts nothing |
| 343 | // about the redirect itself: the code is exchanged server-side, and the grant |
| 344 | // that comes back must name the SAME address as the session and carry an |
| 345 | // auth_time inside the window. Only then is presence recorded and the held |
| 346 | // action resumed. |
| 347 | func (s *Server) handleStepUpCallback(w http.ResponseWriter, r *http.Request) { |
| 348 | addr, raw, ok := s.session(r) |
| 349 | if !ok { |
| 350 | http.Redirect(w, r, "/", http.StatusFound) |
| 351 | return |
| 352 | } |
| 353 | c, err := r.Cookie(stepUpCookie) |
| 354 | if err != nil || c.Value == "" { |
| 355 | s.renderMessage(w, r, http.StatusBadRequest, "Confirmation expired", |
| 356 | "That confirmation is stale or was started in another browser. Nothing has been changed — please try the action again.") |
| 357 | return |
| 358 | } |
| 359 | s.clearCookie(w, stepUpCookie) |
| 360 | parts := strings.SplitN(c.Value, ".", 3) |
| 361 | if len(parts) != 3 || parts[0] == "" || |
| 362 | subtle.ConstantTimeCompare([]byte(parts[0]), []byte(r.URL.Query().Get("state"))) != 1 { |
| 363 | s.renderMessage(w, r, http.StatusBadRequest, "Confirmation mismatch", |
| 364 | "That response didn't match this browser's confirmation attempt. Nothing has been changed — please try the action again.") |
| 365 | return |
| 366 | } |
| 367 | verifier, pendingToken := parts[1], parts[2] |
| 368 | if e := r.URL.Query().Get("error"); e != "" { |
| 369 | // Cancelled at Keymail, or refused there. Not an error state — but |
| 370 | // emphatically not a pass either. |
| 371 | s.store.TakePendingAction(pendingToken, raw, time.Now()) // burn the stash |
| 372 | s.renderMessage(w, r, http.StatusOK, "Not confirmed", |
| 373 | "You didn't confirm it was you, so nothing has been changed.") |
| 374 | return |
| 375 | } |
| 376 | code := r.URL.Query().Get("code") |
| 377 | if code == "" { |
| 378 | s.renderMessage(w, r, http.StatusBadRequest, "Not confirmed", |
| 379 | "Keymail sent no authorization code. Nothing has been changed — please try the action again.") |
| 380 | return |
| 381 | } |
| 382 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 383 | defer cancel() |
| 384 | grant, err := s.km.ExchangeGrant(ctx, code, verifier, true) |
| 385 | if err != nil { |
| 386 | log.Printf("console: step-up exchange: %v", err) |
| 387 | s.renderMessage(w, r, http.StatusBadGateway, "Can't confirm it's you", stepUpUnavailable) |
| 388 | return |
| 389 | } |
| 390 | now := time.Now() |
| 391 | // The identity check. Keymail's identity service is deliberately anonymous — |
| 392 | // a fresh assertion there proves SOME registered passkey was verified, not |
| 393 | // whose. This comparison is what turns it into proof about this session's |
| 394 | // owner: step up with somebody else's passkey and the address won't match. |
| 395 | if !strings.EqualFold(grant.Address, addr) { |
| 396 | log.Printf("console: step-up address mismatch for session of %s", addr) |
| 397 | s.renderMessage(w, r, http.StatusForbidden, "Not confirmed", |
| 398 | "That passkey belongs to a different Keymail address than the one you're signed in as. Nothing has been changed.") |
| 399 | return |
| 400 | } |
| 401 | // A re-authorization rotates the console's grant at Keymail, so the stored |
| 402 | // token would otherwise be dead by the next invite or confirmation send. |
| 403 | if err := s.store.SetGrantToken(grant.Address, grant.Token, now); err != nil { |
| 404 | log.Printf("console: step-up grant refresh: %v", err) |
| 405 | } |
| 406 | // Fail closed on a missing or stale auth_time. An IdP that did not report |
| 407 | // when the gesture happened has not proved that one did. |
| 408 | age := now.Sub(grant.AuthTime) |
| 409 | if grant.AuthTime.IsZero() || age > StepUpWindow || age < -stepUpSkew { |
| 410 | log.Printf("console: step-up produced no usable auth_time (zero=%v age=%s)", grant.AuthTime.IsZero(), age) |
| 411 | s.renderMessage(w, r, http.StatusForbidden, "Can't confirm it's you", stepUpUnavailable) |
| 412 | return |
| 413 | } |
| 414 | if err := s.store.RecordStepUp(raw, addr, grant.AuthTime, now); err != nil { |
| 415 | log.Printf("console: record step-up: %v", err) |
| 416 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your confirmation. Please try the action again.") |
| 417 | return |
| 418 | } |
| 419 | pending, ok := s.store.TakePendingAction(pendingToken, raw, now) |
| 420 | if !ok { |
| 421 | // Confirmed, but the held action is gone (expired, already resumed, or |
| 422 | // belonged to another session). Say so plainly rather than guessing. |
| 423 | s.renderMessage(w, r, http.StatusOK, "Confirmed", |
| 424 | "Thanks — that's you. The action you were doing has expired, so nothing was changed. Please try it again.") |
| 425 | return |
| 426 | } |
| 427 | s.resume(w, r, pending) |
| 428 | } |
| 429 | |
| 430 | // resume re-runs the held POST through the console's own router, so it meets |
| 431 | // every gate it would have met the first time — session, CSRF, membership, |
| 432 | // ownership, and the step-up gate that now passes. Re-dispatching rather than |
| 433 | // calling a handler directly is the point: there is no bypass path, because |
| 434 | // there is no second door. |
| 435 | func (s *Server) resume(w http.ResponseWriter, r *http.Request, p *pendingAction) { |
| 436 | _, raw, ok := s.session(r) |
| 437 | if !ok { |
| 438 | http.Redirect(w, r, "/", http.StatusFound) |
| 439 | return |
| 440 | } |
| 441 | form, err := url.ParseQuery(p.Form) |
| 442 | if err != nil { |
| 443 | log.Printf("console: resume %s %s: %v", p.Method, p.Path, err) |
| 444 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not resume that action. Please try it again.") |
| 445 | return |
| 446 | } |
| 447 | // Put back the one field the stash refuses to hold (see requireFreshAuth): |
| 448 | // the resumed request must satisfy the same CSRF check as any other, and |
| 449 | // the token it satisfies it with is this browser's own, read from its |
| 450 | // cookie, not something recovered from storage. |
| 451 | form.Set("csrf", raw) |
| 452 | req, err := http.NewRequestWithContext(r.Context(), p.Method, p.Path, strings.NewReader(form.Encode())) |
| 453 | if err != nil { |
| 454 | log.Printf("console: resume %s %s: %v", p.Method, p.Path, err) |
| 455 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not resume that action. Please try it again.") |
| 456 | return |
| 457 | } |
| 458 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 459 | // The session cookie rides along — the resumed request is the same browser, |
| 460 | // and every gate it passes it passes on its own credentials, not on ours. |
| 461 | req.Header["Cookie"] = r.Header["Cookie"] |
| 462 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { |
| 463 | req.Header.Set("X-Forwarded-For", xff) |
| 464 | } |
| 465 | req.Host = r.Host |
| 466 | req.RemoteAddr = r.RemoteAddr |
| 467 | s.mux.ServeHTTP(w, req) |
| 468 | } |
| 469 | |