Files

264 lines
7.9 KiB
Go

package main
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func csp(m buildManifest, p string, docs bool) string {
common := "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; "
if docs || strings.HasPrefix(p, "docs/") {
// Firefox checks a sandboxed iframe's blob download against the
// parent's frame-src. Permit local blob URLs without admitting another
// network origin; form submission and script policies stay restricted.
return common + "frame-src " + m.AppOrigin + " blob:; frame-ancestors 'none'"
}
if strings.HasPrefix(p, "embed/") {
return common + "frame-ancestors " + strings.Join(m.DocsOrigins, " ")
}
return common + "frame-ancestors 'none'"
}
func staticSite(dir string, m buildManifest, docs bool) http.Handler {
files := map[string]resource{}
for _, f := range m.Files {
files[f.Path] = f
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Cache-Control", "no-store")
if r.Method != "GET" && r.Method != "HEAD" {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if strings.Contains(r.URL.EscapedPath(), "%") || strings.Contains(r.URL.Path, "\\") {
http.NotFound(w, r)
return
}
p := strings.TrimPrefix(r.URL.Path, "/")
if strings.HasPrefix(p, "r/") {
prefix := "r/" + m.BuildID + "/"
if !strings.HasPrefix(p, prefix) {
http.NotFound(w, r)
return
}
p = strings.TrimPrefix(p, prefix)
}
if p == "" {
if docs {
http.Redirect(w, r, "/docs/ru/first-sprite/", http.StatusFound)
return
}
p = "index.html"
}
if strings.HasSuffix(p, "/") {
p += "index.html"
}
if !validPath(p) {
http.NotFound(w, r)
return
}
if docs && !strings.HasPrefix(p, "docs/") && !strings.HasPrefix(p, "assets/") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Security-Policy", csp(m, p, docs))
if !strings.HasPrefix(p, "embed/") {
w.Header().Set("X-Frame-Options", "DENY")
}
f, ok := files[p]
if p == "manifest.json" && !docs {
b, e := os.ReadFile(filepath.Join(dir, p))
if e != nil {
http.Error(w, "manifest unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", mimeFor(p))
w.Header().Set("ETag", `"`+digest(b)+`"`)
http.ServeContent(w, r, p, time.Time{}, bytes.NewReader(b))
return
}
if !ok {
http.NotFound(w, r)
return
}
original := f
if !strings.HasSuffix(p, ".gz") {
w.Header().Set("Vary", "Accept-Encoding")
if acceptsGzip(r.Header.Get("Accept-Encoding")) {
if gz, exists := files[p+".gz"]; exists {
f = gz
w.Header().Set("Content-Encoding", "gzip")
}
}
}
if hashedFile.MatchString(original.Path) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
w.Header().Set("Content-Type", original.MIME)
w.Header().Set("ETag", `"`+f.SHA256+`"`)
b, e := readBounded(filepath.Join(dir, filepath.FromSlash(f.Path)), maxFileBytes)
if e != nil {
http.Error(w, "resource unavailable", http.StatusServiceUnavailable)
return
}
http.ServeContent(w, r, original.Path, time.Time{}, bytes.NewReader(b))
})
}
func acceptsGzip(s string) bool {
for _, token := range strings.Split(s, ",") {
parts := strings.Split(strings.TrimSpace(token), ";")
if parts[0] != "gzip" {
continue
}
for _, p := range parts[1:] {
key, value, found := strings.Cut(strings.TrimSpace(p), "=")
if found && key == "q" {
quality, err := strconv.ParseFloat(value, 64)
if err != nil || quality <= 0 || quality > 1 {
return false
}
}
}
return true
}
return false
}
func listenLocal(addr string) (net.Listener, error) {
host, _, e := net.SplitHostPort(addr)
if e != nil {
return nil, e
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return nil, fmt.Errorf("development host must bind to an explicit loopback address")
}
return net.Listen("tcp", addr)
}
func serve(dir, addr, docsAddr string) error {
m, _, e := verify(dir, "")
if e != nil {
return e
}
app, e := listenLocal(addr)
if e != nil {
return e
}
defer app.Close()
errc := make(chan error, 2)
if docsAddr != "" {
l, e := listenLocal(docsAddr)
if e != nil {
return e
}
defer l.Close()
go func() {
s := &http.Server{Handler: staticSite(dir, m, true), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
errc <- s.Serve(l)
}()
fmt.Printf("Docs preview: http://%s/docs/ru/first-sprite/\n", l.Addr())
}
fmt.Printf("Asset Studio: http://%s/r/%s/\n", app.Addr(), m.BuildID)
go func() {
s := &http.Server{Handler: staticSite(dir, m, false), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
errc <- s.Serve(app)
}()
return <-errc
}
// httpCheck verifies bytes actually served over HTTP. There are no upload,
// telemetry or authentication endpoints. Redirects are always rejected.
func httpCheck(ctx context.Context, base, dir, expected string) (map[string]any, error) {
m, hash, e := verify(dir, expected)
if e != nil {
return nil, e
}
u, e := url.Parse(base)
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !strings.HasSuffix(u.Path, "/") {
return nil, fmt.Errorf("invalid HTTP base")
}
if !validOrigin(u.Scheme+"://"+u.Host, m.Release) {
return nil, fmt.Errorf("invalid HTTP origin")
}
client := &http.Client{Timeout: 25 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return fmt.Errorf("redirect refused") }}
checked := 0
get := func(p string, want resource) error {
target := u.ResolveReference(&url.URL{Path: p})
req, e := http.NewRequestWithContext(ctx, "GET", target.String(), nil)
if e != nil {
return e
}
req.Header.Set("Accept-Encoding", "identity")
res, e := client.Do(req)
if e != nil {
return e
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("HTTP %d: %s", res.StatusCode, p)
}
if ct := strings.Split(res.Header.Get("Content-Type"), ";")[0]; !compatibleMIME(p, ct, want.MIME) {
return fmt.Errorf("wrong MIME for %s: %s", p, ct)
}
b, e := io.ReadAll(io.LimitReader(res.Body, want.Bytes+1))
if e != nil {
return e
}
if int64(len(b)) != want.Bytes || digest(b) != want.SHA256 {
return fmt.Errorf("HTTP bytes mismatch: %s", p)
}
if strings.HasSuffix(p, ".html") {
policy := res.Header.Get("Content-Security-Policy")
if !strings.Contains(policy, "frame-ancestors") || !strings.Contains(policy, "object-src 'none'") {
return fmt.Errorf("missing CSP: %s", p)
}
if strings.HasPrefix(p, "embed/") {
for _, origin := range m.DocsOrigins {
if !strings.Contains(policy, origin) {
return fmt.Errorf("embed CSP origin missing")
}
}
}
}
checked++
return nil
}
b, _ := os.ReadFile(filepath.Join(dir, "manifest.json"))
if e = get("manifest.json", resource{"manifest.json", int64(len(b)), hash, mimeFor("manifest.json")}); e != nil {
return nil, e
}
for _, f := range m.Files {
if e = get(f.Path, f); e != nil {
return nil, e
}
}
return map[string]any{"buildID": m.BuildID, "manifestSHA256": hash, "filesVerified": checked, "baseURL": base, "status": "PASS"}, nil
}
func compatibleMIME(path, got, want string) bool {
if got == strings.Split(want, ";")[0] {
return true
}
if strings.HasSuffix(path, ".js") && got == "application/javascript" {
return true
}
if strings.HasSuffix(path, ".gz") && (got == "application/gzip" || got == "application/x-gzip") {
return true
}
return false
}