StreamURL built http://user:pass@127.0.0.1:8010/api/cameras/<id>/ stream.mjpeg and handed it to an <img>, with a comment saying the credentials were inline "so an <img> 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 a shop counter was a broken image. Measured against a running engine: the app's Go-side calls returned stats and people while an <img> on that very URL failed, and curl proved the URL answered 200. The engine was never the problem. The password now stays on this side of the process boundary. A loopback relay attaches Basic auth and streams the engine's bytes back unchanged - the same reasoning Shot.jsx already follows at head office, where an <img> equally cannot carry a session. What the relay is careful about, since it is a door onto the biometric API with a credential attached: - loopback only, on a port the OS picks; a fixed one would collide with whatever else a shop PC runs and read as "the cameras broke" - a per-run random token in the path. The engine's own credential exists so the live face feed is never served open; an unauthenticated relay would hand that feed to any other process on the PC. Compared in constant time, and a wrong one is 404, not 403 - an allow-list of stream.mjpeg and frame.jpg. Holding the token does not reach the identity list, the gallery, or erasure - camera ids validated, not interpolated - every chunk flushed; a buffered MJPEG stream is a tile that never paints, which looks identical to the bug being fixed Two of those were written after a test failed, not before: - `..` MATCHES the id pattern, because real camera ids contain dots. `/api/cameras/../stream.mjpeg` is not the endpoint anyone intended. The id can never hold a slash, so `.` and `..` are the whole remaining traversal surface and are now refused by name. - the serve goroutine read p.srv off the struct while stop() was nilling it, so a quick start/stop dereferenced nil and took the process down. Captured before launching now. FrameURL is deliberately not added. No screen asks for a still, and a bound method nothing calls is the same defect as a capability the UI cannot reach, only pointing the other way. Verified: nine unit tests, plus a live test against the real engine and the real office camera - two MJPEG frames, 90,793 bytes, no credential in the URL. Windows and darwin both build; vet clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6
250 lines
8.3 KiB
Go
250 lines
8.3 KiB
Go
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/<id>/stream.mjpeg and hand it
|
|
// to an <img>, with a comment saying the credentials were inline "so an <img>
|
|
// 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 <img> 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 <img> 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/<token>/<camera>/<file>
|
|
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
|
|
}
|
|
}
|
|
}
|