diff --git a/src/handlers/containers/list.go b/src/handlers/containers/list.go index d007b95..6c2a502 100644 --- a/src/handlers/containers/list.go +++ b/src/handlers/containers/list.go @@ -21,22 +21,28 @@ import ( "net/http" "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg" ) -// NewListContainersHandler takes the cache instance from main and wraps it in a handler function -func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc { +// NewListContainersHandler takes the cache instance from main and wraps it in a +// handler function. dataRoot resolves the operator web-link sources; the +// request host drives the derived href fallback. +func NewListContainersHandler(cache *containers.ContainerCache, dataRoot string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cache.RLock() defer cache.RUnlock() w.Header().Set("Content-Type", "application/json") - if cache.GetServices() == nil { + services := cache.GetServices() + if services == nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("No services available\n")) + return } - services := cache.GetServices() + services = resolveWebHrefs(services, dataRoot, r) if err := json.NewEncoder(w).Encode(services); err != nil { log.Printf("Error encoding services: %v", err) http.Error(w, "Error marshaling service data", http.StatusInternalServerError) @@ -44,3 +50,38 @@ func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc } } } + +// resolveWebHrefs copies the service list and resolves each live `web` block's +// href server-side (contract rev 8.2 §16.2, S40/S43). The cached value never +// carries an href, and a block with no valid target is omitted entirely. The +// returned slice is fresh: the cache's Container.Web pointers are never +// mutated. +func resolveWebHrefs(services []types.Container, dataRoot string, r *http.Request) []types.Container { + cfg := webcfg.Load(dataRoot) + out := make([]types.Container, len(services)) + copy(out, services) + for i := range out { + c := &out[i] + if c.Web == nil || !c.Web.Enabled { + c.Web = nil + continue + } + name := c.ServiceName + if name == "" { + name = c.Name + } + w := *c.Web + href, ok := types.ResolveWebHref(&w, types.WebHrefSources{ + ServiceName: name, + OperatorOverride: cfg.OverrideFor(name), + BaseDomainTemplate: cfg.Template(), + }, c.HostPortFor(w.Port), r) + if !ok { + c.Web = nil + continue + } + w.Href = href + c.Web = &w + } + return out +} diff --git a/src/handlers/containers/list_test.go b/src/handlers/containers/list_test.go new file mode 100644 index 0000000..8223335 --- /dev/null +++ b/src/handlers/containers/list_test.go @@ -0,0 +1,153 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package containers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg" +) + +func serveList(t *testing.T, cache *libcontainers.ContainerCache, dataRoot, host string) []types.Container { + t.Helper() + h := NewListContainersHandler(cache, dataRoot) + r := httptest.NewRequest(http.MethodGet, "/api/containers", nil) + r.Host = host + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var out []types.Container + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + // No href may ever carry a non-http(s) scheme, whatever the manifest said. + for _, c := range out { + if c.Web != nil && !strings.HasPrefix(c.Web.Href, "http://") && !strings.HasPrefix(c.Web.Href, "https://") { + t.Fatalf("unsafe href for %s: %q", c.ServiceName, c.Web.Href) + } + } + return out +} + +func byName(items []types.Container) map[string]types.Container { + m := make(map[string]types.Container, len(items)) + for _, c := range items { + m[c.ServiceName] = c + } + return m +} + +// TestListContainersResolvesWebHref covers the headline feature end to end at +// the handler: a service with a valid web block gets a SERVER-resolved href, an +// unsafe manifest url is skipped in favor of the derived host:port, and a +// service with no block, a disabled block, or an invalid port omits `web` +// entirely. The cache itself is never mutated. +func TestListContainersResolvesWebHref(t *testing.T) { + root := t.TempDir() + cache := libcontainers.NewContainerCache() + cache.AddService(types.Container{ + ID: "c1", Name: "calendar", ServiceName: "calendar", Status: "running", + Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}}, + Web: &types.WebApp{Enabled: true, Port: 8080}, + }) + cache.AddService(types.Container{ID: "c2", Name: "no-web", ServiceName: "no-web", Status: "running"}) + cache.AddService(types.Container{ + ID: "c3", Name: "bad", ServiceName: "bad", Status: "running", + Web: &types.WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"}, + }) + cache.AddService(types.Container{ + ID: "c4", Name: "disabled", ServiceName: "disabled", Status: "running", + Web: &types.WebApp{Enabled: false, Port: 8080}, + }) + cache.AddService(types.Container{ + ID: "c5", Name: "badport", ServiceName: "badport", Status: "running", + Web: &types.WebApp{Enabled: true, Port: 0}, + }) + + got := byName(serveList(t, cache, root, "console.example.com:8080")) + + if c := got["calendar"]; c.Web == nil || c.Web.Href != "http://console.example.com:8083" { + t.Fatalf("calendar web = %+v, want derived href http://console.example.com:8083", c.Web) + } + // An unsafe manifest url is skipped, never emitted: the derived host:port + // source takes over. + if c := got["bad"]; c.Web == nil || c.Web.Href != "http://console.example.com:8080" { + t.Fatalf("bad web = %+v, want the derived href (unsafe url skipped)", c.Web) + } + for _, name := range []string{"no-web", "disabled", "badport"} { + if c := got[name]; c.Web != nil { + t.Errorf("%s must omit `web` entirely, got %+v", name, c.Web) + } + } + + // The cached entry must keep the unresolved block (href is per-request). + cached, ok := cache.GetService("calendar") + if !ok { + t.Fatal("calendar missing from cache") + } + if cached.Web == nil || cached.Web.Href != "" { + t.Fatalf("cache was mutated with a resolved href: %+v", cached.Web) + } +} + +// TestListContainersWebSourcePrecedence covers the operator sources: override +// beats the global template, the template beats the derived host:port, and the +// manifest url beats both. +func TestListContainersWebSourcePrecedence(t *testing.T) { + root := t.TempDir() + if err := webcfg.Save(root, webcfg.Config{ + BaseDomainTemplate: "https://.home.example", + Overrides: map[string]string{"override-svc": "https://calendar.home.example"}, + }); err != nil { + t.Fatalf("webcfg.Save: %v", err) + } + + cache := libcontainers.NewContainerCache() + cache.AddService(types.Container{ + ID: "1", Name: "override-svc", ServiceName: "override-svc", Status: "running", + Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}}, + Web: &types.WebApp{Enabled: true, Port: 8080}, + }) + cache.AddService(types.Container{ + ID: "2", Name: "templated", ServiceName: "templated", Status: "running", + Network: types.Network{Ports: []types.PortMapping{{Host: 8084, Guest: 8080}}}, + Web: &types.WebApp{Enabled: true, Port: 8080}, + }) + cache.AddService(types.Container{ + ID: "3", Name: "manifest", ServiceName: "manifest", Status: "running", + Network: types.Network{Ports: []types.PortMapping{{Host: 8085, Guest: 8080}}}, + Web: &types.WebApp{Enabled: true, Port: 8080, URL: "https://manifest.example"}, + }) + + got := byName(serveList(t, cache, root, "console.example.com:8080")) + if c := got["override-svc"]; c.Web == nil || c.Web.Href != "https://calendar.home.example:8080" { + t.Fatalf("override href = %+v", c.Web) + } + if c := got["templated"]; c.Web == nil || c.Web.Href != "https://templated.home.example:8080" { + t.Fatalf("template href = %+v", c.Web) + } + if c := got["manifest"]; c.Web == nil || c.Web.Href != "https://manifest.example:8080" { + t.Fatalf("manifest href = %+v", c.Web) + } +} diff --git a/src/handlers/containers/web_evidence_test.go b/src/handlers/containers/web_evidence_test.go new file mode 100644 index 0000000..b1e478b --- /dev/null +++ b/src/handlers/containers/web_evidence_test.go @@ -0,0 +1,178 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package containers + +import ( + "encoding/json" + "strings" + "testing" + + libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" +) + +// TestWebChipEvidenceTable is the raw-evidence generator for task-29's headline +// feature. It logs, for each manifest `web` source the operator or a service +// can supply, exactly what `GET /api/containers` returns: the resolved href, or +// `` when no valid source exists. Run with: +// +// go test ./handlers/containers/ -run TestWebChipEvidenceTable -v +func TestWebChipEvidenceTable(t *testing.T) { + root := t.TempDir() + cache := libcontainers.NewContainerCache() + + type row struct { + name string + web *types.WebApp + } + rows := []row{ + {"valid-derived", &types.WebApp{Enabled: true, Port: 8080}}, + {"url-javascript", &types.WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"}}, + {"url-data", &types.WebApp{Enabled: true, Port: 8080, URL: "data:text/html,"}}, + {"url-file", &types.WebApp{Enabled: true, Port: 8080, URL: "file:///etc/passwd"}}, + {"url-userinfo", &types.WebApp{Enabled: true, Port: 8080, URL: "https://user:pw@evil.example"}}, + {"url-control-char", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/\x01"}}, + {"url-newline", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/\n"}}, + {"url-space", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/a b"}}, + {"url-angle-bracket", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/"}}, + {"url-empty-host", &types.WebApp{Enabled: true, Port: 8080, URL: "https:///path"}}, + {"url-no-scheme", &types.WebApp{Enabled: true, Port: 8080, URL: "evil.example/x"}}, + {"url-valid-https", &types.WebApp{Enabled: true, Port: 8080, URL: "https://calendar.example/app"}}, + {"port-0", &types.WebApp{Enabled: true, Port: 0}}, + {"port--1", &types.WebApp{Enabled: true, Port: -1}}, + {"port-65536", &types.WebApp{Enabled: true, Port: 65536}}, + {"listener-disabled", &types.WebApp{Enabled: false, Port: 8080}}, + {"no-web-block", nil}, + } + for i, r := range rows { + cache.AddService(types.Container{ + ID: "ev" + r.name, + Name: r.name, + ServiceName: r.name, + Status: "running", + Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}}, + Web: r.web, + }) + _ = i + } + + got := byName(serveList(t, cache, root, "console.example.com:8080")) + for _, r := range rows { + c := got[r.name] + if c.Web == nil { + t.Logf("%-18s -> web=", r.name) + continue + } + raw, _ := json.Marshal(c.Web) + t.Logf("%-18s -> web=%s", r.name, raw) + } +} + +// TestWebChipEvidenceNoFallback covers the acceptance case where an unsafe +// TestWebChipEvidenceUnsafeURLNeverBecomesHref is the acceptance evidence for +// the rejection cases. Contract §16.2 says an *invalid present source is +// skipped* and the next source in precedence is used, so with an enabled block +// and a valid declared port the derived `http://:` +// source still yields a safe href — the unsafe manifest string is NEVER the +// link. `web` is omitted entirely only when no source can produce a valid href +// (no block, disabled, invalid port — see TestWebChipEvidenceTable). +func TestWebChipEvidenceUnsafeURLNeverBecomesHref(t *testing.T) { + root := t.TempDir() + cache := libcontainers.NewContainerCache() + + unsafe := map[string]string{ + "javascript": "javascript:alert(1)", + "data": "data:text/html,", + "file": "file:///etc/passwd", + "userinfo": "https://user:pass@evil.example", + "control": "https://evil.example/\x01", + "newline": "https://evil.example/\n", + } + for name, url := range unsafe { + cache.AddService(types.Container{ + ID: "nf" + name, + Name: name, + ServiceName: name, + Status: "running", + // No host port binding: the derived source falls back to the + // declared port, so the result is http://:8080. + Web: &types.WebApp{Enabled: true, Port: 8080, URL: url}, + }) + } + + got := byName(serveList(t, cache, root, "console.example.com:8080")) + for name, url := range unsafe { + c := got[name] + if c.Web == nil { + t.Logf("%-10s -> web=", name) + continue + } + t.Logf("%-10s -> href=%q (unsafe url %q never used)", name, c.Web.Href, url) + if c.Web.Href == url || strings.Contains(c.Web.Href, "evil.example") || strings.Contains(c.Web.Href, "javascript:") || + strings.Contains(c.Web.Href, "data:") || strings.Contains(c.Web.Href, "file:") || strings.Contains(c.Web.Href, "user:") { + t.Errorf("%s: unsafe url leaked into href: %q", name, c.Web.Href) + } + if !strings.HasPrefix(c.Web.Href, "http://") && !strings.HasPrefix(c.Web.Href, "https://") { + t.Errorf("%s: href is not http(s): %q", name, c.Web.Href) + } + } +} + +// TestWebChipEvidenceInvalidPortOmitted is the "no valid href can be built" +// acceptance case: even an otherwise valid web block with an invalid port has +// no source at all, so `/api/containers` omits `web` entirely (no chip, no +// link). +func TestWebChipEvidenceInvalidPortOmitted(t *testing.T) { + root := t.TempDir() + cache := libcontainers.NewContainerCache() + for name, port := range map[string]int{"port0": 0, "portNeg": -1, "portBig": 65536} { + cache.AddService(types.Container{ + ID: "ip" + name, Name: name, ServiceName: name, Status: "running", + Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}}, + Web: &types.WebApp{Enabled: true, Port: port, URL: "https://calendar.example"}, + }) + } + got := byName(serveList(t, cache, root, "console.example.com:8080")) + for name := range map[string]int{"port0": 0, "portNeg": -1, "portBig": 65536} { + if c := got[name]; c.Web != nil { + t.Errorf("%s must omit `web` entirely, got %+v", name, c.Web) + } + t.Logf("%-8s -> web=", name) + } +} + +// TestManifestNonNumericPortFailsDecode is the evidence for the non-numeric +// port case: a manifest whose `web.port` is not a number cannot decode, so the +// service never advertises a web app at all (fail-closed at the boundary). +func TestManifestNonNumericPortFailsDecode(t *testing.T) { + for _, body := range []string{ + `{"name":"svc","web":{"enabled":true,"port":"abc"}}`, + `{"name":"svc","web":{"enabled":true,"port":"8080"}}`, + } { + var m types.Manifest + err := json.Unmarshal([]byte(body), &m) + t.Logf("%s -> err=%v", body, err) + if err == nil { + t.Fatalf("non-numeric web.port must fail manifest decode: %s", body) + } + } + // A numeric port decodes fine (control case). + var ok types.Manifest + if err := json.Unmarshal([]byte(`{"name":"svc","web":{"enabled":true,"port":8080}}`), &ok); err != nil { + t.Fatalf("numeric web.port must decode: %v", err) + } + t.Logf("numeric port control -> web=%+v", ok.Web) +} diff --git a/src/handlers/weblinks/weblinks.go b/src/handlers/weblinks/weblinks.go new file mode 100644 index 0000000..9fa6c24 --- /dev/null +++ b/src/handlers/weblinks/weblinks.go @@ -0,0 +1,108 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package weblinks exposes the operator-controlled URL sources used by the Web +// App capability (contract rev 8.2 §16.2): a global base-domain template and +// per-service overrides. Owner-gated; the values are only ever base URLs, and +// every source is re-validated at resolution time. +package weblinks + +import ( + "encoding/json" + "net/http" + + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg" +) + +// Handler serves GET/PUT /api/web-links. +type Handler struct { + dataRoot string +} + +// NewHandler builds the owner-gated web-links handler. +func NewHandler(dataRoot string) *Handler { + return &Handler{dataRoot: dataRoot} +} + +// Root handles both the read and the full-replacement write. PUT is a full +// replacement: omitted fields clear, matching the catalog's PUT semantics. +func (h *Handler) Root(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.get(w) + case http.MethodPut: + h.put(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED") + } +} + +func (h *Handler) get(w http.ResponseWriter) { + cfg := webcfg.Load(h.dataRoot) + if cfg.Overrides == nil { + cfg.Overrides = map[string]string{} + } + writeJSON(w, http.StatusOK, cfg) +} + +func (h *Handler) put(w http.ResponseWriter, r *http.Request) { + var body webcfg.Config + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") + return + } + if body.BaseDomainTemplate != "" && !containsServicePlaceholder(body.BaseDomainTemplate) { + writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") + return + } + if !types.ValidWebTemplate(body.BaseDomainTemplate) { + writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") + return + } + for service, raw := range body.Overrides { + if service == "" || !types.ValidWebBase(raw) { + writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") + return + } + } + if body.Overrides == nil { + body.Overrides = map[string]string{} + } + if err := webcfg.Save(h.dataRoot, body); err != nil { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + writeJSON(w, http.StatusOK, body) +} + +func containsServicePlaceholder(tpl string) bool { + for i := 0; i+9 <= len(tpl); i++ { + if tpl[i:i+9] == "" { + return true + } + } + return false +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"code": code}}) +} diff --git a/src/lib/containers/installed.go b/src/lib/containers/installed.go index 0519f07..b119bbc 100644 --- a/src/lib/containers/installed.go +++ b/src/lib/containers/installed.go @@ -70,6 +70,7 @@ func (cache *ContainerCache) LoadInstalledContainers() error { HasAuth: metadata.HasAuth, PublicKey: metadata.PublicKey, Config: metadata.Config, + Web: metadata.Web, Title: metadata.Title, Vendor: metadata.Vendor, Author: metadata.Author, @@ -104,6 +105,10 @@ func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest) if m.Config != nil { metadata.Config = m.Config } + // The manifest's `web` block is announced only while the listener is + // enabled and serving (honesty rule §16.1), so it replaces the persisted + // value — including clearing it when the service stops advertising one. + metadata.Web = m.Web if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil { logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err) } diff --git a/src/lib/types/service.go b/src/lib/types/service.go index fcae4c5..7ceccc2 100644 --- a/src/lib/types/service.go +++ b/src/lib/types/service.go @@ -28,6 +28,21 @@ type Manifest struct { // exactly one key; the key names the field and its value is the field spec. // The manifest is a pure schema — it never carries a value. Config []ConfigItem `json:"config,omitempty"` + // Web is the Web App capability (contract rev 8.2 §16.1). A service + // announces it only while its browser listener is enabled and serving + // (honesty rule). Maestro validates it and resolves Href server-side; the + // manifest's own "href" is ignored. + Web *WebApp `json:"web,omitempty"` +} + +// WebApp is the Web App capability (contract rev 8.2). A service declares it in +// its manifest; Maestro validates it and resolves Href server-side. The +// manifest's own "href" is ignored — Href is response-only. +type WebApp struct { + Enabled bool `json:"enabled"` + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` + Href string `json:"href,omitempty"` } // ConfigItem is a single-key config declaration in a service manifest. The diff --git a/src/lib/types/web.go b/src/lib/types/web.go new file mode 100644 index 0000000..1eb24e1 --- /dev/null +++ b/src/lib/types/web.go @@ -0,0 +1,178 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package types + +import ( + "net" + "net/http" + "net/url" + "strconv" + "strings" +) + +// WebHrefSources are the Maestro-side, operator-controlled URL sources layered +// above the (untrusted) manifest url. Precedence (S43), highest first: +// manifest web.url -> OperatorOverride -> BaseDomainTemplate -> derived host:port. +type WebHrefSources struct { + ServiceName string + OperatorOverride string + BaseDomainTemplate string // e.g. "https://." +} + +// ResolveWebHref turns an untrusted manifest web block plus the operator URL +// sources into a link target. ok=false means the caller must omit `web` from +// the response entirely (rule 4). hostPort is the host-published port bound to +// web.Port (0 when unpublished). +func ResolveWebHref(web *WebApp, src WebHrefSources, hostPort int, r *http.Request) (string, bool) { + if web == nil || !web.Enabled { + return "", false + } + if web.Port < 1 || web.Port > 65535 { + return "", false + } + // Sources 1-3: explicit bases, highest precedence first. A present but + // invalid source is skipped (logged by the caller), never emitted. A raw + // source carrying a control character is rejected outright — trimming + // whitespace must not launder a smuggled newline into a chip target. + for _, raw := range []string{ + web.URL, + src.OperatorOverride, + renderTemplate(src.BaseDomainTemplate, src.ServiceName), + } { + if raw == "" || hasControlChars(raw) { + continue + } + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if href, ok := normalizeBase(raw, web.Port); ok { + return href, true + } + } + // Source 4: derive from the console request host + published port. + host := requestHostname(r) + if host == "" { + return "", false + } + if hostPort < 1 || hostPort > 65535 { + hostPort = web.Port + } + if hostPort < 1 || hostPort > 65535 { + return "", false + } + scheme := "http" + if r != nil && r.TLS != nil { + scheme = "https" + } + return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(hostPort)), true +} + +// ValidWebBase reports whether raw is an acceptable operator-supplied base URL +// (http/https, a host, no userinfo, no control chars). Used by the owner-gated +// web-links API to reject an obviously bad override early; resolution still +// re-validates every source, so this is a convenience, not the security gate. +func ValidWebBase(raw string) bool { + if raw == "" { + return true + } + _, ok := normalizeBase(raw, 0) + return ok +} + +// ValidWebTemplate is ValidWebBase for the global base-domain template: the +// literal placeholder is expected there and substituted before +// validation (at resolution time renderTemplate does the same). +func ValidWebTemplate(raw string) bool { + return ValidWebBase(strings.ReplaceAll(raw, "", "service")) +} + +// normalizeBase validates a candidate base URL (http/https, no userinfo, no +// control chars or unresolved template markers) and applies the declared port +// when the URL carries no explicit port and the port is not the scheme default. +// ok=false means "invalid, skip to the next source". +func normalizeBase(raw string, declared int) (string, bool) { + if strings.ContainsAny(raw, " \t\r\n<>") || hasControlChars(raw) { + return "", false + } + u, err := url.Parse(raw) + if err != nil || u.User != nil || u.Hostname() == "" { + return "", false + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", false + } + if u.Port() == "" && declared != schemeDefaultPort(u.Scheme) { + u.Host = net.JoinHostPort(u.Hostname(), strconv.Itoa(declared)) + return u.String(), true + } + return raw, true +} + +// hasControlChars reports whether s carries a C0 control character or DEL. +// url.Parse tolerates some of them (e.g. a bare \x00 in a path), so the check +// is explicit: a manifest url is never allowed to smuggle them into a chip. +func hasControlChars(s string) bool { + for _, r := range s { + if r < 0x20 || r == 0x7f { + return true + } + } + return false +} + +// renderTemplate substitutes in the global base-domain template. +func renderTemplate(tpl, service string) string { + tpl = strings.TrimSpace(tpl) + if tpl == "" || service == "" { + return "" + } + return strings.ReplaceAll(tpl, "", service) +} + +// HostPortFor returns the host-published port bound to the container port +// guest, or 0 when the port is not published. +func (c Container) HostPortFor(guest int) int { + for _, p := range c.Network.Ports { + if p.Guest == guest && p.Host > 0 { + return p.Host + } + } + return 0 +} + +func schemeDefaultPort(scheme string) int { + if scheme == "https" { + return 443 + } + return 80 +} + +// requestHostname returns the hostname of the console request (port stripped), +// or "" when it is unusable. Bracketed IPv6 literals are unwrapped. +func requestHostname(r *http.Request) string { + if r == nil { + return "" + } + h := strings.TrimSpace(r.Host) + if h == "" || strings.ContainsAny(h, "/\\@?# \t\r\n") || hasControlChars(h) { + return "" + } + if host, _, err := net.SplitHostPort(h); err == nil { + return host + } + return strings.Trim(h, "[]") +} diff --git a/src/lib/types/web_test.go b/src/lib/types/web_test.go new file mode 100644 index 0000000..f193b9f --- /dev/null +++ b/src/lib/types/web_test.go @@ -0,0 +1,187 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package types + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func req(host string, isTLS bool) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/containers", nil) + r.Host = host + if isTLS { + r.TLS = &tls.ConnectionState{} + } + return r +} + +func TestResolveWebHrefManifestWins(t *testing.T) { + web := &WebApp{Enabled: true, Port: 8080, URL: "https://app.example.com"} + href, ok := ResolveWebHref(web, WebHrefSources{ + ServiceName: "keepnote", + OperatorOverride: "https://override.example.com", + BaseDomainTemplate: "https://.example.com", + }, 8081, req("192.168.1.5:8080", false)) + if !ok { + t.Fatal("expected a resolved href") + } + // The declared port is applied because the URL carries no explicit port. + if want := "https://app.example.com:8080"; href != want { + t.Fatalf("href = %q, want %q", href, want) + } +} + +func TestResolveWebHrefPrecedence(t *testing.T) { + base := WebHrefSources{ + ServiceName: "keepnote", + OperatorOverride: "https://override.example.com", + BaseDomainTemplate: "https://.example.com", + } + r := req("192.168.1.5:8080", false) + + // Invalid manifest url is skipped → operator override wins. + href, ok := ResolveWebHref(&WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"}, base, 0, r) + if !ok || href != "https://override.example.com:8080" { + t.Fatalf("override fallback = %q ok=%v", href, ok) + } + + // Invalid override skipped → template renders with the service name. + src := base + src.OperatorOverride = "data:text/html,x" + href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 0, r) + if !ok || href != "https://keepnote.example.com:8080" { + t.Fatalf("template fallback = %q ok=%v", href, ok) + } + + // All explicit sources invalid → derived from the request host + published port. + src = WebHrefSources{ServiceName: "keepnote", OperatorOverride: "file:///etc/passwd", BaseDomainTemplate: "not a url"} + href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 8081, r) + if !ok || href != "http://192.168.1.5:8081" { + t.Fatalf("derived fallback = %q ok=%v", href, ok) + } + + // No published port → the declared port is used. + href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 0, r) + if !ok || href != "http://192.168.1.5:8080" { + t.Fatalf("derived declared-port fallback = %q ok=%v", href, ok) + } + + // TLS request → https derived link. + href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 0, req("console.example.com", true)) + if !ok || href != "https://console.example.com:8080" { + t.Fatalf("derived tls fallback = %q ok=%v", href, ok) + } +} + +func TestResolveWebHrefRejectsUnsafeSources(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"javascript scheme", "javascript:alert(1)"}, + {"data scheme", "data:text/html;base64,PHNjcmlwdD4="}, + {"file scheme", "file:///etc/passwd"}, + {"userinfo", "https://user:secret@example.com/"}, + {"control char", "https://example.com/\x01"}, + {"newline", "https://example.com/\n"}, + {"space", "https://exa mple.com/"}, + {"angle bracket", "https://example.com/