package connect import ( "bytes" "encoding/base64" "encoding/json" "math" "net/http" "net/url" "time" "amadan.net/oficina/home/handoff" ) const localLinkNavigationCookie = "__Host-oficina-local-link-navigation" const localLinkNavigationLifetime = int64(600) const localLinkNavigationLimit = 1024 const localLinkCompletePath = "/auth/local/complete" type localLinkMarker struct { Version int `json:"v"` Action string `json:"action"` Issued int64 `json:"issued"` Expires int64 `json:"expires"` } type localLinkNavigation struct { landingPath string sealer handoff.Sealer sessionCookie string readSession func(*http.Request) (*Session, error) namespace string fail func(http.ResponseWriter, *http.Request, int) origin, host string now func() time.Time } func newLocalLinkNavigation(sealer handoff.Sealer, c RoutesConfig) *localLinkNavigation { u, err := url.Parse(c.Origin) if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || sealer == nil || c.ReadSession == nil { return nil } landing := c.LocalLandingPath if landing == "" { landing = "/" } if !handoff.SafePath(landing) { return nil } return &localLinkNavigation{landingPath: landing, sealer: sealer, sessionCookie: c.SessionCookie, readSession: c.ReadSession, namespace: c.Namespace, origin: c.Origin, host: u.Host, now: time.Now, fail: func(w http.ResponseWriter, r *http.Request, status int) { c.Render(w, r, "failed", "", status) }} } func localNavigationCookie(w http.ResponseWriter, name, value string, age int) { http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: age}) } func (n *localLinkNavigation) purpose() string { return n.namespace + ".local-link-navigation.v1:" + n.origin } func (n *localLinkNavigation) guard(w http.ResponseWriter, r *http.Request, path string) bool { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) n.fail(w, r, http.StatusMethodNotAllowed) return false } if r.Host != n.host || r.URL.Path != path || r.URL.RawQuery != "" { http.Redirect(w, r, "/", http.StatusSeeOther) return false } return true } // Begin abandons browser pointers to an old link. Rotation and freshness also // invalidate its binding; clearing cookies does not consume database rows. // A cross-site top-level GET may abandon a flow, but cannot create a link. func (n *localLinkNavigation) Begin(w http.ResponseWriter, r *http.Request) { localNavigationCookie(w, localLinkNavigationCookie, "", -1) if !n.guard(w, r, "/account/home") { return } localNavigationCookie(w, homeInitCookie, "", -1) localNavigationCookie(w, homeProofCookie, "", -1) now := n.now().Unix() if now <= 0 || now > math.MaxInt64-localLinkNavigationLifetime { http.Redirect(w, r, "/", http.StatusSeeOther) return } payload, _ := json.Marshal(localLinkMarker{Version: 1, Action: "link-start", Issued: now, Expires: now + localLinkNavigationLifetime}) sealed, err := n.sealer.Seal(n.purpose(), payload) if err != nil { n.fail(w, r, http.StatusServiceUnavailable) return } encoded := base64.RawURLEncoding.EncodeToString(sealed) if len(encoded) > localLinkNavigationLimit { n.fail(w, r, http.StatusServiceUnavailable) return } localNavigationCookie(w, localLinkNavigationCookie, encoded, int(localLinkNavigationLifetime)) http.Redirect(w, r, "/signin/local", http.StatusSeeOther) } func (n *localLinkNavigation) marker(r *http.Request, now int64) bool { value := "" count := 0 for _, c := range r.Cookies() { if c.Name == localLinkNavigationCookie { count++ value = c.Value } } if count != 1 || value == "" || len(value) > localLinkNavigationLimit { return false } sealed, err := base64.RawURLEncoding.Strict().DecodeString(value) if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value { return false } payload, err := n.sealer.Open(n.purpose(), sealed) if err != nil || len(payload) > localLinkNavigationLimit { return false } var marker localLinkMarker if json.Unmarshal(payload, &marker) != nil { return false } // Only this helper writes the payload. Exact re-encoding excludes duplicate // and unknown fields, alternate numbers, and any trailing JSON value. canonical, _ := json.Marshal(marker) if !bytes.Equal(payload, canonical) { return false } return marker.Version == 1 && marker.Action == "link-start" && marker.Issued > 0 && marker.Issued <= now && now < marker.Expires && marker.Expires > marker.Issued && marker.Expires-marker.Issued <= localLinkNavigationLifetime } // Complete reads the newly presented session after local verification. It // cannot initiate or confirm linking, and never treats a Home mint as local // proof. A copied marker can at most repeat navigation to the same page. func (n *localLinkNavigation) Complete(w http.ResponseWriter, r *http.Request) { localNavigationCookie(w, localLinkNavigationCookie, "", -1) if !n.guard(w, r, localLinkCompletePath) { return } now := n.now() target := n.landingPath if n.marker(r, now.Unix()) { count := 0 for _, c := range r.Cookies() { if c.Name == n.sessionCookie { count++ } } if count == 1 { if session, err := n.readSession(r); err == nil && session != nil { if _, fresh := FreshLocal(*session, now); fresh { target = "/account/home" } } } } http.Redirect(w, r, target, http.StatusSeeOther) }