package web
import (
"net/http"
"strconv"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// RequireHTTPS keeps a forge whose public origin is https from being
// used over plain http through its proxy.
//
// The tunnel in front of the forge accepts http:// as readily as
// https://, and says which one the reader used in X-Forwarded-Proto.
// Before this, a typed http:// URL signed people in and cloned private
// repositories in the clear between the reader and the proxy.
//
// A page load is redirected, because nothing secret has been sent yet.
// Anything else (a form post, the API, a sync round) is refused rather
// than redirected: its credential and body have already crossed the
// network by the time the forge sees it, and a client that followed a
// redirect would send them a second time.
//
// Only a request the proxy marks as http is touched. A request with no
// forwarded scheme came straight to the listener — the runner and the
// probes on the forge's own box, over loopback — and is served as
// before. A forge with no https origin is left alone entirely.
func RequireHTTPS(publicOrigin string, next http.Handler) http.Handler {
if !strings.HasPrefix(publicOrigin, "https://") {
return next
}
base := strings.TrimSuffix(publicOrigin, "/")
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch strings.ToLower(req.Header.Get("X-Forwarded-Proto")) {
case "http":
if req.Method == http.MethodGet || req.Method == http.MethodHead {
http.Redirect(w, req, base+req.URL.RequestURI(), http.StatusPermanentRedirect)
return
}
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "This forge only accepts "+req.Method+" over https. Use "+
base+req.URL.Path+" instead; if you cloned with an http:// URL, "+
"change the remote to https:// and change your password or sync token, "+
"which has been sent unencrypted.", http.StatusForbidden)
return
case "https":
// Browsers that have seen this once will not try http again.
// No includeSubDomains: other names under the domain are not
// this forge's to promise for.
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, req)
})
}
// cacheFor is the Cache-Control for a response whose bytes are the same
// for everyone who may read them, like an image by hash.
//
// It says public only when the reader is anonymous. A request with no
// identity that got this far proves anyone may read these bytes, so a
// shared cache holding them gives nothing away. A signed-in reader may be
// reading a private repository, and the edge cache in front of the forge
// would otherwise keep that reader's bytes and hand them to the next
// stranger to ask for the same URL. private keeps the reader's own
// browser cache and nobody else's.
func cacheFor(req *http.Request, maxAge int, immutable bool) string {
scope := "public"
// Any credential counts, not only a resolved one: a feed reader or
// a client may authenticate in the handler rather than the
// middleware, and a credential nobody resolved is still a reader
// who may be reading something private.
if ops.User(req.Context()) != "" || req.Header.Get("Authorization") != "" || cookieToken(req) != "" {
scope = "private"
}
v := scope + ", max-age=" + strconv.Itoa(maxAge)
if immutable {
v += ", immutable"
}
return v
}