Skip to content
quarry Artifact [acfded6804]
Login

Artifact [acfded6804]

Artifact acfded680457260a1a0c012c7bae59ced2c6443c342d7d0d151fba7b619044ed:


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 trusted proxy vouches for and marks as http is
// touched, by the same rule the rest of the web believes forwarded
// headers by; a scheme header from anyone else is ignored. 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, proxy TrustedProxy, 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 proxy.forwardedProto(req) {
		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
}

// PrivateByDefault gives a response that says nothing about caching
// "private, no-cache": keep it in the reader's own browser, ask before
// reusing it, and never in a shared cache.
//
// The edge in front of the forge decides by the URL's extension when
// the origin is silent or says only no-cache. A file page for
// "logo.gif" is HTML about one reader's view of one repository, but the
// edge stored it and told the browser to keep it for four hours (#223).
// private is the word it honours. A handler that sets its own policy —
// an immutable asset, cacheFor's public image, a no-store stream —
// keeps it.
func PrivateByDefault(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		next.ServeHTTP(&privateDefaultWriter{ResponseWriter: w}, req)
	})
}

// privateDefaultWriter fills in Cache-Control at the moment the header
// goes out. Flush and Unwrap are forwarded for the same reason as
// cspWriter's: streams type-assert for Flusher, and
// http.ResponseController looks through the wrapper.
type privateDefaultWriter struct {
	http.ResponseWriter
	wrote bool
}

func (p *privateDefaultWriter) WriteHeader(status int) {
	if !p.wrote {
		p.wrote = true
		if cc := p.Header().Get("Cache-Control"); cc == "" || cc == "no-cache" {
			p.Header().Set("Cache-Control", "private, no-cache")
		}
	}
	p.ResponseWriter.WriteHeader(status)
}

func (p *privateDefaultWriter) Write(b []byte) (int, error) {
	if !p.wrote {
		p.WriteHeader(http.StatusOK)
	}
	return p.ResponseWriter.Write(b)
}

func (p *privateDefaultWriter) Flush() {
	if !p.wrote {
		p.WriteHeader(http.StatusOK)
	}
	if f, ok := p.ResponseWriter.(http.Flusher); ok {
		f.Flush()
	}
}

func (p *privateDefaultWriter) Unwrap() http.ResponseWriter { return p.ResponseWriter }