Files
2026-08-11 20:15:06 -04:00

411 lines
14 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync/atomic"
)
const (
maxInjectableHTML = 16 << 20
reloadClient = `(function(){var id="__himesan_overlay";function show(e){var d=document.getElementById(id);if(!d){d=document.createElement("dialog");d.id=id;var b=document.createElement("button");b.textContent="Close";b.addEventListener("click",function(){d.close()});var p=document.createElement("pre");d.appendChild(b);d.appendChild(p);document.body.appendChild(d)}var p=d.querySelector("pre"),xs=e.diagnostics||[];p.textContent=(e.phase?e.phase+": ":"")+(e.message||"Hime-san could not reload")+(xs.length?"\n\n"+xs.map(function(x){return (x.path||"")+(x.line?":"+x.line+(x.column?":"+x.column:""):"")+(x.code?" ["+x.code+"]":"")+" "+x.message}).join("\n"):"");if(!d.open)d.showModal()}var s=new EventSource("/__himesan/events");s.addEventListener("reload",function(){location.reload()});s.addEventListener("diagnostic",function(e){try{show(JSON.parse(e.data))}catch(_){show({message:e.data})}})})();`
)
var (
reloadClientTag = []byte("<script data-himesan-reload>" + reloadClient + "</script>")
reloadClientHash = makeReloadClientHash()
)
func makeReloadClientHash() string {
digest := sha256.Sum256([]byte(reloadClient))
return "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'"
}
type developmentProxy struct {
target atomic.Pointer[url.URL]
authority atomic.Pointer[localProxyAuthority]
hub *eventHub
proxy *httputil.ReverseProxy
}
type localProxyAuthority struct {
port int
}
func newDevelopmentProxy(hub *eventHub) *developmentProxy {
d := &developmentProxy{hub: hub}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
d.proxy = &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(request *httputil.ProxyRequest) {
target := d.target.Load()
if target == nil {
return
}
request.SetURL(target)
request.SetXForwarded()
request.Out.Header.Set("Accept-Encoding", "identity")
request.Out.Header.Del("If-Modified-Since")
request.Out.Header.Del("If-None-Match")
request.Out.Header.Set("Cache-Control", "no-cache")
},
ModifyResponse: injectDevelopmentClient,
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
http.Error(w, "Hime-san development upstream is unavailable: "+err.Error(), http.StatusBadGateway)
},
}
return d
}
func (d *developmentProxy) closeIdleConnections() {
if transport, ok := d.proxy.Transport.(interface{ CloseIdleConnections() }); ok {
transport.CloseIdleConnections()
}
}
func (d *developmentProxy) setAuthority(address string) error {
if err := ValidateLoopbackAddress(address); err != nil {
return fmt.Errorf("development proxy authority %q: %w", address, err)
}
_, rawPort, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("split development proxy authority: %w", err)
}
port, err := strconv.Atoi(rawPort)
if err != nil {
return fmt.Errorf("parse development proxy port: %w", err)
}
d.authority.Store(&localProxyAuthority{port: port})
return nil
}
func (d *developmentProxy) setTarget(address string) error {
if err := ValidateLoopbackAddress(address); err != nil {
return fmt.Errorf("development upstream %q: %w", address, err)
}
target, err := url.Parse("http://" + address)
if err != nil {
return fmt.Errorf("parse development upstream: %w", err)
}
d.target.Store(target)
return nil
}
func (d *developmentProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if status, message := d.validateRequest(r); status != 0 {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.Error(w, message, status)
return
}
if r.URL.Path == eventsPath {
d.hub.serveHTTP(w, r)
return
}
if d.target.Load() == nil {
d.serveWaitingPage(w)
return
}
d.proxy.ServeHTTP(w, r)
}
func (d *developmentProxy) validateRequest(r *http.Request) (int, string) {
authority := d.authority.Load()
if authority == nil {
return http.StatusServiceUnavailable, "Hime-san development proxy is not ready"
}
requestAuthority, ok := canonicalLoopbackAuthority(r.Host, authority.port)
if !ok {
return http.StatusMisdirectedRequest, "Hime-san development proxy requires a loopback Host authority"
}
if values := r.Header.Values("Sec-Fetch-Site"); len(values) > 1 {
return http.StatusForbidden, "cross-origin development proxy request denied"
} else if len(values) == 1 {
switch strings.ToLower(strings.TrimSpace(values[0])) {
case "", "none", "same-origin":
default:
return http.StatusForbidden, "cross-origin development proxy request denied"
}
}
origins := r.Header.Values("Origin")
if len(origins) > 1 {
return http.StatusForbidden, "cross-origin development proxy request denied"
}
if len(origins) == 1 {
originAuthority, ok := canonicalHTTPOrigin(origins[0], authority.port)
if !ok || originAuthority != requestAuthority {
return http.StatusForbidden, "cross-origin development proxy request denied"
}
}
return 0, ""
}
func canonicalHTTPOrigin(raw string, port int) (string, bool) {
if raw == "" || strings.TrimSpace(raw) != raw {
return "", false
}
origin, err := url.Parse(raw)
if err != nil || !strings.EqualFold(origin.Scheme, "http") || origin.Host == "" || origin.User != nil || origin.Opaque != "" || origin.Path != "" || origin.RawPath != "" || origin.RawQuery != "" || origin.Fragment != "" || origin.ForceQuery {
return "", false
}
return canonicalLoopbackAuthority(origin.Host, port)
}
func canonicalLoopbackAuthority(authority string, requiredPort int) (string, bool) {
if authority == "" || strings.TrimSpace(authority) != authority {
return "", false
}
host, rawPort, err := net.SplitHostPort(authority)
if err != nil {
if requiredPort != 80 {
return "", false
}
host = authority
rawPort = "80"
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
}
requestPort, err := strconv.Atoi(rawPort)
if err != nil || requestPort != requiredPort {
return "", false
}
normalizedHost := strings.ToLower(host)
if normalizedHost == "localhost" || normalizedHost == "localhost." {
return "localhost:" + strconv.Itoa(requiredPort), true
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return "", false
}
return ip.String() + ":" + strconv.Itoa(requiredPort), true
}
func (d *developmentProxy) serveWaitingPage(w http.ResponseWriter) {
body := append([]byte("<!doctype html><html><body><h1>Hime-san is waiting for a healthy application build.</h1>"), reloadClientTag...)
body = append(body, []byte("</body></html>")...)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src "+reloadClientHash+"; connect-src 'self'")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write(body)
}
func injectDevelopmentClient(response *http.Response) error {
disableDevelopmentCaching(response)
if !eligibleHTMLResponse(response) {
return nil
}
prefix, err := io.ReadAll(io.LimitReader(response.Body, maxInjectableHTML+1))
if err != nil {
return fmt.Errorf("read HTML for development reload injection: %w", err)
}
if len(prefix) > maxInjectableHTML {
response.Body = &prefixedReadCloser{
Reader: io.MultiReader(bytes.NewReader(prefix), response.Body),
Closer: response.Body,
}
return nil
}
if err := response.Body.Close(); err != nil {
return fmt.Errorf("close upstream HTML response: %w", err)
}
if !isFullHTMLDocument(prefix) {
response.Body = io.NopCloser(bytes.NewReader(prefix))
response.ContentLength = int64(len(prefix))
response.Header.Set("Content-Length", strconv.Itoa(len(prefix)))
return nil
}
contents := insertReloadClient(prefix)
response.Body = io.NopCloser(bytes.NewReader(contents))
response.ContentLength = int64(len(contents))
response.Header.Set("Content-Length", strconv.Itoa(len(contents)))
response.Header.Del("ETag")
response.Header.Del("Last-Modified")
adjustCSP(response.Header, "Content-Security-Policy")
adjustCSP(response.Header, "Content-Security-Policy-Report-Only")
return nil
}
func disableDevelopmentCaching(response *http.Response) {
response.Header.Set("Cache-Control", "no-store")
response.Header.Set("Pragma", "no-cache")
response.Header.Set("Expires", "0")
response.Header.Del("ETag")
response.Header.Del("Last-Modified")
}
func eligibleHTMLResponse(response *http.Response) bool {
if response.StatusCode != http.StatusOK || response.Request == nil || response.Body == nil || response.Request.Method == http.MethodHead {
return false
}
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(mediaType, "text/html") {
return false
}
if encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")); encoding != "" && !strings.EqualFold(encoding, "identity") {
return false
}
rawDisposition := strings.TrimSpace(response.Header.Get("Content-Disposition"))
disposition, _, dispositionErr := mime.ParseMediaType(rawDisposition)
if strings.EqualFold(disposition, "attachment") || (dispositionErr != nil && strings.HasPrefix(strings.ToLower(rawDisposition), "attachment")) {
return false
}
request := response.Request
for _, header := range []string{"HX-Request", "Turbo-Frame", "X-PJAX", "X-Requested-With"} {
if strings.TrimSpace(request.Header.Get(header)) != "" {
return false
}
}
if strings.EqualFold(response.Header.Get("X-Himesan-Fragment"), "true") {
return false
}
destination := strings.TrimSpace(request.Header.Get("Sec-Fetch-Dest"))
return destination == "" || strings.EqualFold(destination, "document")
}
func isFullHTMLDocument(contents []byte) bool {
remaining := bytes.TrimSpace(contents)
remaining = bytes.TrimSpace(bytes.TrimPrefix(remaining, []byte{0xef, 0xbb, 0xbf}))
for bytes.HasPrefix(remaining, []byte("<!--")) {
end := bytes.Index(remaining[4:], []byte("-->"))
if end < 0 {
return false
}
remaining = bytes.TrimSpace(remaining[4+end+3:])
}
lower := bytes.ToLower(remaining)
return hasHTMLTokenPrefix(lower, "<!doctype html") || hasHTMLTokenPrefix(lower, "<html")
}
func hasHTMLTokenPrefix(contents []byte, prefix string) bool {
if !bytes.HasPrefix(contents, []byte(prefix)) || len(contents) == len(prefix) {
return false
}
next := contents[len(prefix)]
return next == '>' || next == '/' || next == ' ' || next == '\t' || next == '\r' || next == '\n' || next == '\f'
}
func insertReloadClient(contents []byte) []byte {
lower := bytes.ToLower(contents)
position := bytes.LastIndex(lower, []byte("</body>"))
if position < 0 {
position = bytes.LastIndex(lower, []byte("</html>"))
}
if position < 0 {
position = len(contents)
}
result := make([]byte, 0, len(contents)+len(reloadClientTag))
result = append(result, contents[:position]...)
result = append(result, reloadClientTag...)
result = append(result, contents[position:]...)
return result
}
func adjustCSP(header http.Header, name string) {
policies := header.Values(name)
if len(policies) == 0 {
return
}
header.Del(name)
for _, policy := range policies {
policy = addCSPSource(policy, "script-src", reloadClientHash, "default-src")
// CSP3 gives script-src-elem precedence over script-src for an inline
// <script>. Preserve that directive's restrictions while granting the
// same single hash, otherwise a policy such as script-src-elem 'none'
// silently blocks the injected reload client.
policy = addCSPSource(policy, "script-src-elem", reloadClientHash, "script-src")
policy = addCSPSource(policy, "connect-src", "'self'", "default-src")
header.Add(name, policy)
}
}
func addCSPSource(policy, directive, source, fallback string) string {
parts := strings.Split(policy, ";")
fallbackSources := []string(nil)
fallbackSeen := false
for index, raw := range parts {
fields := strings.Fields(raw)
if len(fields) == 0 {
continue
}
// CSP ignores duplicate directives after the first occurrence. Mirror
// that rule when deriving a missing directive from its fallback so an
// ignored, more-permissive duplicate cannot broaden the development page.
if !fallbackSeen && strings.EqualFold(fields[0], fallback) {
fallbackSeen = true
fallbackSources = append([]string(nil), fields[1:]...)
}
if !strings.EqualFold(fields[0], directive) {
continue
}
for _, existing := range fields[1:] {
if existing == source {
return strings.Join(parts, ";")
}
}
currentSources := withoutCSPNone(fields[1:])
parts[index] = fields[0]
if len(currentSources) != 0 {
parts[index] += " " + strings.Join(currentSources, " ")
}
parts[index] += " " + source
return strings.Join(parts, ";")
}
if !fallbackSeen {
// Without this directive or a default-src fallback the resource is
// already unrestricted; introducing a directive would unnecessarily
// restrict the application under test.
return policy
}
addition := directive
if retained := withoutCSPNone(fallbackSources); len(retained) != 0 {
addition += " " + strings.Join(retained, " ")
}
addition += " " + source
if strings.TrimSpace(policy) == "" {
return addition
}
if strings.HasSuffix(strings.TrimSpace(policy), ";") {
return policy + " " + addition
}
return policy + "; " + addition
}
func withoutCSPNone(sources []string) []string {
result := make([]string, 0, len(sources))
for _, source := range sources {
if !strings.EqualFold(source, "'none'") {
result = append(result, source)
}
}
return result
}
type prefixedReadCloser struct {
io.Reader
io.Closer
}