326 lines
11 KiB
Go
326 lines
11 KiB
Go
package webapp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Runtime serves only validated immutable release files through capability
|
|
// URLs. It has no dependency on the ambient Kandev session middleware.
|
|
type Runtime struct {
|
|
tokens *TokenManager
|
|
artifacts *ArtifactStore
|
|
validate BindingValidator
|
|
protocol ProtocolHandler
|
|
frameAncestors []string
|
|
}
|
|
|
|
// ProtocolHandler serves the relative application API after Runtime has
|
|
// validated the bearer capability. path is relative to the `_kandev/` root;
|
|
// the handler must apply its own response-size and method limits.
|
|
type ProtocolHandler func(http.ResponseWriter, *http.Request, string, CapabilityBinding, string)
|
|
|
|
const runtimeCapabilityPath = "/api/v1/plugins/web-apps/runtime/"
|
|
|
|
func NewRuntime(tokens *TokenManager, artifacts *ArtifactStore, validate BindingValidator, frameAncestors []string) *Runtime {
|
|
if tokens == nil {
|
|
tokens = NewTokenManager(nil)
|
|
}
|
|
if len(frameAncestors) == 0 {
|
|
frameAncestors = []string{"http://127.0.0.1:38429", "tauri://localhost", "http://tauri.localhost"}
|
|
}
|
|
return &Runtime{tokens: tokens, artifacts: artifacts, validate: validate, frameAncestors: append([]string(nil), frameAncestors...)}
|
|
}
|
|
|
|
// FrameAncestorsForConfig returns the exact browser and desktop shell origins
|
|
// that may embed a capability runtime. Ports are explicit because a wildcard
|
|
// frame-src/frame-ancestors rule would let an unrelated local service frame a
|
|
// canvas. webOrigin is the launcher-provided SPA origin when it differs from
|
|
// the backend origin.
|
|
func FrameAncestorsForConfig(backendPort, webPort int, webOrigin string) ([]string, error) {
|
|
if backendPort < 1 || backendPort > 65535 || webPort < 1 || webPort > 65535 {
|
|
return nil, fmt.Errorf("webapp: invalid runtime origin port")
|
|
}
|
|
origins := []string{
|
|
fmt.Sprintf("http://localhost:%d", backendPort),
|
|
fmt.Sprintf("http://127.0.0.1:%d", backendPort),
|
|
fmt.Sprintf("http://localhost:%d", webPort),
|
|
fmt.Sprintf("http://127.0.0.1:%d", webPort),
|
|
"tauri://localhost",
|
|
"http://tauri.localhost",
|
|
}
|
|
if strings.TrimSpace(webOrigin) != "" {
|
|
origins = append(origins, webOrigin)
|
|
}
|
|
return normalizeFrameAncestors(origins)
|
|
}
|
|
|
|
// SetProtocolHandler attaches the versioned browser data protocol. It is
|
|
// configured during startup before the HTTP server accepts requests.
|
|
func (rt *Runtime) SetProtocolHandler(handler ProtocolHandler) {
|
|
if rt == nil {
|
|
return
|
|
}
|
|
rt.protocol = handler
|
|
}
|
|
|
|
// IssueCapabilityPath creates a short-lived capability URL path. The caller
|
|
// supplies the already-authorized binding; the path is relative so the host
|
|
// can use it from either the browser SPA or the desktop shell without trusting
|
|
// an inbound Host header.
|
|
func (rt *Runtime) IssueCapabilityPath(binding CapabilityBinding, ttl time.Duration) (string, error) {
|
|
if rt == nil || rt.tokens == nil {
|
|
return "", ErrRuntimeTokenInvalid
|
|
}
|
|
token, err := rt.tokens.Issue(binding, ttl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return runtimeCapabilityPath + url.PathEscape(token) + "/", nil
|
|
}
|
|
|
|
// Serve handles one capability URL request. path is the path below the
|
|
// capability segment; an empty path serves the declared entry document.
|
|
func (rt *Runtime) Serve(w http.ResponseWriter, r *http.Request, token, requestPath string) {
|
|
if rt == nil || rt.tokens == nil {
|
|
writeRuntimeError(w, http.StatusNotFound, ErrRuntimeTokenInvalid)
|
|
return
|
|
}
|
|
binding, err := rt.tokens.Validate(token)
|
|
if err != nil {
|
|
writeRuntimeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
if rt.validate != nil {
|
|
if err := rt.validate(r.Context(), binding); err != nil {
|
|
writeRuntimeError(w, runtimeAuthorizationStatus(err), err)
|
|
return
|
|
}
|
|
}
|
|
if requestPath == "_kandev/host-runtime.js" || requestPath == "/_kandev/host-runtime.js" {
|
|
rt.serveHostRuntime(w, r, binding)
|
|
return
|
|
}
|
|
if protocolPath, ok := runtimeProtocolPath(requestPath); ok {
|
|
if rt.protocol == nil {
|
|
writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable)
|
|
return
|
|
}
|
|
rt.protocol(w, r, token, binding, protocolPath)
|
|
return
|
|
}
|
|
name, err := runtimeFilePath(requestPath, binding.Entry)
|
|
if err != nil {
|
|
writeRuntimeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
if rt.artifacts == nil {
|
|
writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable)
|
|
return
|
|
}
|
|
file, err := rt.artifacts.Open(binding.Artifact, name)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable)
|
|
return
|
|
}
|
|
writeRuntimeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
policy, err := BuildContentSecurityPolicy(binding.NetworkOrigins, rt.frameAncestors)
|
|
if err != nil {
|
|
writeRuntimeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
setRuntimeHeaders(w, policy, r.Header.Get("Origin"))
|
|
contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(name)))
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
w.Header().Set("Content-Type", contentType)
|
|
data, err := io.ReadAll(io.LimitReader(file, MaxFileBytes+1))
|
|
if err != nil {
|
|
writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable)
|
|
return
|
|
}
|
|
if int64(len(data)) > MaxFileBytes {
|
|
writeRuntimeError(w, http.StatusNotFound, ErrRuntimeBootstrapUnavailable)
|
|
return
|
|
}
|
|
if name == strings.TrimPrefix(strings.TrimSpace(binding.Entry), "/") {
|
|
data, err = injectRuntimeBootstrap(data)
|
|
if err != nil {
|
|
writeRuntimeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func (rt *Runtime) serveHostRuntime(w http.ResponseWriter, r *http.Request, binding CapabilityBinding) {
|
|
policy, err := BuildContentSecurityPolicy(binding.NetworkOrigins, rt.frameAncestors)
|
|
if err != nil {
|
|
writeRuntimeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
setRuntimeHeaders(w, policy, r.Header.Get("Origin"))
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(hostRuntimeBootstrap)))
|
|
_, _ = w.Write([]byte(hostRuntimeBootstrap))
|
|
}
|
|
|
|
// Handler returns a standard-library handler bound to one capability token.
|
|
// Backend routing uses this helper when its router already extracted the
|
|
// token and the remainder path.
|
|
func (rt *Runtime) Handler(token, requestPath string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rt.Serve(w, r, token, requestPath)
|
|
})
|
|
}
|
|
|
|
func runtimeFilePath(requestPath, entry string) (string, error) {
|
|
entryName, err := normalizePackagePath(strings.TrimPrefix(strings.TrimSpace(entry), "/"), MaxPathBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if runtimePathInDistribution(entryName) {
|
|
return "", ErrUnsafePath
|
|
}
|
|
name := strings.TrimPrefix(strings.TrimSpace(requestPath), "/")
|
|
if name == "" {
|
|
return entryName, nil
|
|
}
|
|
name, err = url.PathUnescape(name)
|
|
if err != nil {
|
|
return "", ErrUnsafePath
|
|
}
|
|
if strings.HasPrefix(name, "_kandev/") || name == "_kandev" {
|
|
return "", ErrUnsafePath
|
|
}
|
|
name, err = normalizePackagePath(name, MaxPathBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// The capability URL is rooted at the application entry directory. This
|
|
// keeps ordinary relative references such as ./app.js and ./app.css
|
|
// working when the manifest entry is nested (for example ui/index.html),
|
|
// while still allowing an explicit package-root path such as ui/app.js.
|
|
entryDir := path.Dir(entryName)
|
|
if entryDir != "." && !strings.HasPrefix(name, entryDir+"/") {
|
|
name = path.Join(entryDir, name)
|
|
}
|
|
name, err = normalizePackagePath(name, MaxPathBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if runtimePathInDistribution(name) {
|
|
return "", ErrUnsafePath
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
func runtimePathInDistribution(name string) bool {
|
|
for _, component := range strings.Split(name, "/") {
|
|
if component == "distribution" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func runtimeProtocolPath(requestPath string) (string, bool) {
|
|
name := strings.TrimPrefix(strings.TrimSpace(requestPath), "/")
|
|
if name == "_kandev" {
|
|
return "", true
|
|
}
|
|
if strings.HasPrefix(name, "_kandev/") {
|
|
return strings.TrimPrefix(name, "_kandev/"), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func setRuntimeHeaders(w http.ResponseWriter, policy, origin string) {
|
|
w.Header().Set("Content-Security-Policy", policy)
|
|
w.Header().Set("Cache-Control", "no-store, no-transform")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
|
|
if origin == "null" {
|
|
w.Header().Set("Access-Control-Allow-Origin", "null")
|
|
w.Header().Add("Vary", "Origin")
|
|
}
|
|
}
|
|
|
|
// SetProtocolHeaders applies the headers shared by capability API and event
|
|
// responses. It is exported for the protocol adapter, while asset responses
|
|
// additionally receive the response CSP from setRuntimeHeaders.
|
|
func SetProtocolHeaders(w http.ResponseWriter, origin string) {
|
|
w.Header().Set("Cache-Control", "no-store, no-transform")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
|
|
if origin == "null" {
|
|
w.Header().Set("Access-Control-Allow-Origin", "null")
|
|
w.Header().Add("Vary", "Origin")
|
|
}
|
|
}
|
|
|
|
func writeRuntimeError(w http.ResponseWriter, status int, err error) {
|
|
code := runtimeErrorCode(err)
|
|
body, marshalErr := json.Marshal(struct {
|
|
Error string `json:"error"`
|
|
}{Error: code})
|
|
if marshalErr != nil {
|
|
body = []byte(`{"error":"runtime_unavailable"}`)
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Del("Content-Length")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
func runtimeAuthorizationStatus(err error) int {
|
|
if errors.Is(err, ErrRuntimeTokenStale) {
|
|
return http.StatusUnauthorized
|
|
}
|
|
return http.StatusForbidden
|
|
}
|
|
|
|
func runtimeErrorCode(err error) string {
|
|
switch {
|
|
case errors.Is(err, ErrRuntimeTokenInvalid):
|
|
return ErrRuntimeTokenInvalid.Error()
|
|
case errors.Is(err, ErrRuntimeTokenExpired):
|
|
return ErrRuntimeTokenExpired.Error()
|
|
case errors.Is(err, ErrRuntimeTokenStale):
|
|
return ErrRuntimeTokenStale.Error()
|
|
case errors.Is(err, ErrArtifactUnavailable):
|
|
return ErrArtifactUnavailable.Error()
|
|
case errors.Is(err, ErrUnsafePath):
|
|
return ErrUnsafePath.Error()
|
|
default:
|
|
return "runtime_unavailable"
|
|
}
|
|
}
|
|
|
|
// Keep context in this file's API surface so implementations of a validator
|
|
// can use request cancellation without importing the HTTP package elsewhere.
|
|
var _ BindingValidator = func(context.Context, CapabilityBinding) error { return nil }
|