package main // streamProxy serves the engine's camera feeds to this app's own webview // without putting a credential in the page. // // What this replaces: StreamURL used to build // http://user:pass@127.0.0.1:8010/api/cameras//stream.mjpeg and hand it // to an , with a comment saying the credentials were inline "so an // tag can load it". It cannot. Chromium strips credentials from subresource // URLs and has since M59, and WebView2 is Chromium - so on the one platform // this product ships to, every camera tile on the shop floor renders as a // broken image. Measured against the same running engine: the app's Go-side // calls returned stats and people while an on the very same URL failed, // and curl proved the URL itself answered 200. The engine was never the // problem; the browser was throwing the password away before it asked. // // So the password stays on this side of the process boundary. The webview // asks this loopback listener, the listener attaches Basic auth and relays // the engine's bytes back unchanged. It is the same reasoning the head-office // web app already follows in Shot.jsx, where an equally cannot carry a // session and the bytes are fetched and handed over as an object URL. import ( "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "net" "net/http" "net/url" "regexp" "strings" "sync" "time" ) // A camera id reaches this from the engine and from a person typing into the // Add Camera form. Validated rather than interpolated: without this a `..` // would climb out of the two paths below and turn a camera relay into a proxy // for any engine endpoint, with the credential helpfully attached. var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`) // safeCameraID is the character check AND the two names that pass it and still // mean something to a path resolver. // // The pattern allows `.` because real camera ids contain them - which means it // also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not // the endpoint anyone intended. The id can never contain a slash (the path is // split on them before we get here), so these two strings are the entire // remaining traversal surface. Found by the test, not by reading the regex. func safeCameraID(id string) bool { if id == "." || id == ".." { return false } return safeCameraIDChars.MatchString(id) } type streamProxy struct { mu sync.RWMutex ln net.Listener srv *http.Server client *http.Client token string target string // engine origin, e.g. http://127.0.0.1:8010 user string pass string } func newStreamProxy() *streamProxy { return &streamProxy{} } // start binds a loopback listener and begins relaying. Calling it again while // running is a no-op, so a restarted engine cannot leave two listeners behind. func (p *streamProxy) start(base, user, pass string) error { p.mu.Lock() defer p.mu.Unlock() if p.srv != nil { return nil } if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { base = "http://" + base } if _, err := url.Parse(base); err != nil { return fmt.Errorf("engine base %q: %w", base, err) } // The engine's own credential exists precisely so that the live face feed // is never served open - CLAUDE.md is explicit that an unauthenticated // listener would expose it. An unauthenticated loopback relay would hand // that same feed to any other process on this PC, which on a shop counter // is not a theoretical set. A per-run token, minted here and given only to // this app's own webview, keeps the relay as private as the engine is. raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return fmt.Errorf("proxy token: %w", err) } // Port 0: the OS picks a free one. A fixed port would collide with // whatever else a shop PC happens to be running, and the failure would be // "the cameras stopped working" with nothing pointing at the cause. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return fmt.Errorf("stream proxy listen: %w", err) } p.ln = ln p.token = hex.EncodeToString(raw) p.target = strings.TrimRight(base, "/") p.user, p.pass = user, pass // No client timeout: an MJPEG stream is endless by design and any deadline // would cut the picture off mid-shift. The request context ends it when // the webview navigates away or the tile is replaced. p.client = &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, TLSHandshakeTimeout: 5 * time.Second, }, } srv := &http.Server{Handler: http.HandlerFunc(p.handle)} p.srv = srv // srv and ln are captured, not read off the struct inside the goroutine: // stop() sets both to nil, so a serve loop that reached for them after a // quick start/stop would dereference nil and take the whole app down. The // test that stops the relay found exactly that. go func() { _ = srv.Serve(ln) }() return nil } func (p *streamProxy) stop() { p.mu.Lock() srv, ln := p.srv, p.ln p.srv, p.ln, p.token = nil, nil, "" p.mu.Unlock() if srv != nil { _ = srv.Close() } if ln != nil { _ = ln.Close() } } // urlFor returns the loopback URL for one camera resource, or "" when the // proxy is not running so the caller can fall back. func (p *streamProxy) urlFor(cameraID, file string) string { p.mu.RLock() defer p.mu.RUnlock() if p.ln == nil || p.token == "" || !safeCameraID(cameraID) { return "" } return fmt.Sprintf("http://%s/s/%s/%s/%s", p.ln.Addr().String(), p.token, cameraID, file) } func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) { p.mu.RLock() token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client p.mu.RUnlock() if token == "" || client == nil { http.NotFound(w, r) return } // /s/// parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") if len(parts) != 4 || parts[0] != "s" { http.NotFound(w, r) return } // Constant time: the token is the only thing standing between another // local process and a live view of customers' faces. if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 { // 404 rather than 403. There is nothing here to tell an unwelcome // caller they have found the right door with the wrong key. http.NotFound(w, r) return } cameraID := parts[2] if !safeCameraID(cameraID) { http.NotFound(w, r) return } // An allow-list, not a prefix match. Everything else the engine serves - // the identity list, the gallery, erasure - stays unreachable through here // even for a caller holding the token. // // frame.jpg is listed although no screen asks for one yet. It is reachable // only through urlFor, which is internal, so it adds no bound API nobody // calls; it is here so that adding a still later is a change to a screen // rather than a change to the one file where a mistake is a credentialed // proxy onto the biometric API. var enginePath string switch parts[3] { case "stream.mjpeg": enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg" case "frame.jpg": enginePath = "/api/cameras/" + cameraID + "/frame.jpg" default: http.NotFound(w, r) return } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil) if err != nil { http.Error(w, "bad upstream request", http.StatusInternalServerError) return } // frame.jpg takes width and quality; the engine re-encodes on demand. req.URL.RawQuery = r.URL.RawQuery if user != "" { req.SetBasicAuth(user, pass) } resp, err := client.Do(req) if err != nil { http.Error(w, "engine unreachable", http.StatusBadGateway) return } defer resp.Body.Close() for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} { if v := resp.Header.Get(h); v != "" { w.Header().Set(h, v) } } w.WriteHeader(resp.StatusCode) // Copied by hand rather than with io.Copy so every chunk is flushed. An // MJPEG stream never ends, so anything buffered waiting for a full buffer // is a tile that stays blank forever - which is the same symptom as the // bug this file exists to fix, and would look like it had not worked. flusher, _ := w.(http.Flusher) buf := make([]byte, 32*1024) for { n, rerr := resp.Body.Read(buf) if n > 0 { if _, werr := w.Write(buf[:n]); werr != nil { return // webview went away } if flusher != nil { flusher.Flush() } } if rerr != nil { return } } }