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
297 lines
9.7 KiB
Go
297 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
|
|
// the real one does when a credential is configured, and records what it was
|
|
// asked for.
|
|
type fakeEngine struct {
|
|
*httptest.Server
|
|
gotPath string
|
|
gotUser string
|
|
gotPass string
|
|
hadAuth bool
|
|
}
|
|
|
|
func newFakeEngine(t *testing.T, body string) *fakeEngine {
|
|
t.Helper()
|
|
f := &fakeEngine{}
|
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.gotPath = r.URL.Path
|
|
if r.URL.RawQuery != "" {
|
|
f.gotPath += "?" + r.URL.RawQuery
|
|
}
|
|
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
|
|
if !f.hadAuth {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
_, _ = io.WriteString(w, body)
|
|
}))
|
|
t.Cleanup(f.Close)
|
|
return f
|
|
}
|
|
|
|
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
|
|
t.Helper()
|
|
p := newStreamProxy()
|
|
if err := p.start(engine, user, pass); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
t.Cleanup(p.stop)
|
|
return p
|
|
}
|
|
|
|
func get(t *testing.T, url string) (int, string) {
|
|
t.Helper()
|
|
c := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := c.Get(url)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(b)
|
|
}
|
|
|
|
// The whole point: the webview gets a URL it can actually load, and the
|
|
// password stays behind. A credential in the src is both unloadable in a
|
|
// Chromium webview and readable by anything that can see the DOM.
|
|
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
|
|
|
|
u := p.urlFor("cam2", "stream.mjpeg")
|
|
if u == "" {
|
|
t.Fatal("no url while the proxy is running")
|
|
}
|
|
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
|
|
t.Fatalf("credential leaked into the tile URL: %s", u)
|
|
}
|
|
if !strings.HasPrefix(u, "http://127.0.0.1:") {
|
|
t.Fatalf("relay must be loopback only, got %s", u)
|
|
}
|
|
}
|
|
|
|
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
|
|
engine := newFakeEngine(t, "frame-bytes")
|
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
|
|
|
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("want 200 through the relay, got %d", code)
|
|
}
|
|
if body != "frame-bytes" {
|
|
t.Fatalf("body not relayed unchanged: %q", body)
|
|
}
|
|
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
|
|
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
|
|
engine.hadAuth, engine.gotUser)
|
|
}
|
|
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
|
|
t.Fatalf("wrong upstream path: %s", engine.gotPath)
|
|
}
|
|
}
|
|
|
|
// The token is what keeps every other process on a shop PC from opening a live
|
|
// view of customers' faces, now that the relay itself has no password.
|
|
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
|
addr := p.ln.Addr().String()
|
|
|
|
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
|
|
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
|
|
if code, _ := get(t, url); code != http.StatusNotFound {
|
|
t.Fatalf("token %q got %d, want 404", bad, code)
|
|
}
|
|
}
|
|
if engine.hadAuth {
|
|
t.Fatal("a rejected request still reached the engine")
|
|
}
|
|
}
|
|
|
|
// A camera id is interpolated into the upstream path, so it has to be a camera
|
|
// id and not a way to walk to a different endpoint with the credential
|
|
// attached.
|
|
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
|
addr := p.ln.Addr().String()
|
|
|
|
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
|
|
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
|
|
code, _ := get(t, url)
|
|
if code != http.StatusNotFound {
|
|
t.Fatalf("camera id %q got %d, want 404", bad, code)
|
|
}
|
|
}
|
|
if strings.Contains(engine.gotPath, "identities") {
|
|
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
|
|
}
|
|
}
|
|
|
|
// Only the two files a tile needs. The engine also serves the identity list and
|
|
// the erasure endpoint; holding the token must not open those.
|
|
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
|
addr := p.ln.Addr().String()
|
|
|
|
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
|
|
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
|
|
if code, _ := get(t, url); code != http.StatusNotFound {
|
|
t.Fatalf("file %q got %d, want 404", bad, code)
|
|
}
|
|
}
|
|
|
|
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
|
|
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
|
|
if code, _ := get(t, url); code != http.StatusOK {
|
|
t.Fatalf("file %q got %d, want 200", good, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
|
|
// relay that dropped the query would silently serve full-size frames.
|
|
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
|
|
|
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
|
|
if code, _ := get(t, url); code != http.StatusOK {
|
|
t.Fatalf("got %d", code)
|
|
}
|
|
if !strings.Contains(engine.gotPath, "width=640") ||
|
|
!strings.Contains(engine.gotPath, "quality=70") {
|
|
t.Fatalf("query dropped: %s", engine.gotPath)
|
|
}
|
|
}
|
|
|
|
// An engine that is not running must read as a bad gateway, not as a hang. A
|
|
// blank tile that never resolves is the symptom this whole file exists to end.
|
|
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
|
|
// Port 1 on loopback: nothing listens, and the connection is refused
|
|
// rather than dropped, so this is fast and deterministic.
|
|
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
|
|
|
|
done := make(chan int, 1)
|
|
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
|
|
select {
|
|
case code := <-done:
|
|
if code != http.StatusBadGateway {
|
|
t.Fatalf("want 502, got %d", code)
|
|
}
|
|
case <-time.After(8 * time.Second):
|
|
t.Fatal("a dead engine left the request hanging")
|
|
}
|
|
}
|
|
|
|
// Stopping must actually free the port, or a restarted engine leaves listeners
|
|
// behind for the life of the process.
|
|
func TestStoppingReleasesEverything(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := newStreamProxy()
|
|
if err := p.start(engine.URL, "u", "p"); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
url := p.urlFor("cam2", "stream.mjpeg")
|
|
if code, _ := get(t, url); code != http.StatusOK {
|
|
t.Fatalf("want 200 before stop, got %d", code)
|
|
}
|
|
|
|
p.stop()
|
|
|
|
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
|
|
t.Fatalf("still handing out URLs after stop: %s", got)
|
|
}
|
|
c := &http.Client{Timeout: 3 * time.Second}
|
|
if resp, err := c.Get(url); err == nil {
|
|
resp.Body.Close()
|
|
t.Fatal("listener still accepting after stop")
|
|
}
|
|
}
|
|
|
|
// start twice must not leave two listeners, which is what a restarted engine
|
|
// would otherwise cause.
|
|
func TestStartingTwiceIsANoOp(t *testing.T) {
|
|
engine := newFakeEngine(t, "frames")
|
|
p := startProxy(t, engine.URL, "u", "p")
|
|
|
|
first := p.urlFor("cam2", "stream.mjpeg")
|
|
if err := p.start(engine.URL, "u", "p"); err != nil {
|
|
t.Fatalf("second start: %v", err)
|
|
}
|
|
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
|
|
t.Fatalf("second start moved the relay: %s -> %s", first, second)
|
|
}
|
|
}
|
|
|
|
// Against the real engine, which the unit tests above deliberately do not
|
|
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
|
|
// live store tests follow: the suite must stay runnable with no services.
|
|
//
|
|
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
|
|
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
|
|
//
|
|
// It exists because everything above proves the relay against a fake that
|
|
// agrees with me. Only a real engine proves the thing that was actually
|
|
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
|
|
// rather than being buffered into a tile that never paints.
|
|
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
|
|
base := os.Getenv("TEST_ENGINE_URL")
|
|
if base == "" {
|
|
t.Skip("set TEST_ENGINE_URL to run the live relay test")
|
|
}
|
|
cam := os.Getenv("TEST_ENGINE_CAMERA")
|
|
if cam == "" {
|
|
cam = "cam2"
|
|
}
|
|
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
|
|
|
|
url := p.urlFor(cam, "stream.mjpeg")
|
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
resp, err := (&http.Client{}).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("relay: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
|
|
t.Fatalf("not a stream: Content-Type %q", ct)
|
|
}
|
|
|
|
// Read until two JPEG start markers have gone past. One proves it opened;
|
|
// two prove it is still delivering, which is the difference between a
|
|
// working tile and a single frozen frame.
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
var seen, total int
|
|
buf := make([]byte, 16*1024)
|
|
for seen < 2 && time.Now().Before(deadline) {
|
|
n, rerr := resp.Body.Read(buf)
|
|
total += n
|
|
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
|
|
if rerr != nil {
|
|
break
|
|
}
|
|
}
|
|
if seen < 2 {
|
|
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
|
|
}
|
|
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
|
|
}
|