carlos / platform Public

console: attach door — 404-branch pinned byte-identical, host case-normalized

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell eb769e8b1c6ef0174223579cd2cc84fe35f91532 parent 3a7f6c6
2 files changed, +56 −8
  • internal/console/appdomains.go +8 −8
  • internal/console/appdomains_test.go +48 −0
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go
index 304828d61..ec23703c9 100644
--- a/internal/console/appdomains.go
+++ b/internal/console/appdomains.go
@@ -40,7 +40,7 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- host := strings.TrimSpace(r.FormValue("host"))
+ host := strings.ToLower(strings.TrimSpace(r.FormValue("host")))
channel := strings.TrimSpace(r.FormValue("channel"))
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
@@ -75,8 +75,8 @@ func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- host := strings.TrimSpace(r.FormValue("host"))
- if strings.TrimSpace(r.FormValue("confirm")) != host {
+ host := strings.ToLower(strings.TrimSpace(r.FormValue("host")))
+ if strings.ToLower(strings.TrimSpace(r.FormValue("confirm"))) != host {
s.renderMessage(w, r, http.StatusUnprocessableEntity, "Refused", "Type the domain exactly to detach it.")
return
}
@@ -92,11 +92,11 @@ func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
}
// renderDoorRefusal turns a doorRefusal from the Task 3 core into this
-// door's page. 404 MUST be http.NotFound: the family's byte-identical
-// unknown-app answer, not a rendered message that would tell a prober "an
-// app exists here, but this host does not" versus "nothing exists here at
-// all". Every other status renders through renderMessage with the
-// refusal's own status and member-facing text.
+// door's page. 404 MUST be http.NotFound: the stranger's byte-identical
+// 404, not a rendered message that would tell a prober "an app exists
+// here, but this host does not" versus "nothing exists here at all".
+// Every other status renders through renderMessage with the refusal's own
+// status and member-facing text.
func (s *Server) renderDoorRefusal(w http.ResponseWriter, r *http.Request, ref *doorRefusal) {
if ref.Status == http.StatusNotFound {
http.NotFound(w, r)
diff --git a/internal/console/appdomains_test.go b/internal/console/appdomains_test.go
index 702937843..4a82252f8 100644
--- a/internal/console/appdomains_test.go
+++ b/internal/console/appdomains_test.go
@@ -59,6 +59,30 @@ func TestAppDomainAttachNeedsCSRF(t *testing.T) {
claimAbsent(t, fx, host)
}
+// TestAppDomainAttachLowercasesHost: a member typing Docs.Example.com must
+// not be refused by ValidHostShape's lowercase-only label regex — the door
+// normalizes case before the host ever reaches the core, and the claim
+// lands at the normalized key, not the one typed.
+func TestAppDomainAttachLowercasesHost(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ const mixed = "Docs.Example.com"
+ const lower = "docs.example.com"
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
+ url.Values{"csrf": {csrf}, "host": {mixed}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
+ }
+ if _, _, err := domainclaim.Get(context.Background(), fx.rel, lower); err != nil {
+ t.Errorf("claim at the lowercase key: %v, want it there", err)
+ }
+ if _, _, err := domainclaim.Get(context.Background(), fx.rel, mixed); err == nil {
+ t.Error("a claim also landed at the mixed-case key — the door did not normalize")
+ }
+}
+
// TestAppDomainAttachIsOwnerOnly matches TestAppDeleteIsOwnerOnly's shape:
// a member gets the same byte-identical 404 a total stranger would, and
// nothing is claimed.
@@ -136,6 +160,30 @@ func TestAppDomainDetachHappyPath(t *testing.T) {
claimAbsent(t, fx, host)
}
+// TestAppDomainDetachUnattachedHostIs404 exercises renderDoorRefusal's 404
+// branch: the owner's own gates all pass (CSRF, RoleOwner, confirm == host),
+// but the core has nothing to release, so its doorRefusal{404,...} must
+// render as the stranger's own byte-identical 404 — never a message that
+// would tell a prober "an app exists here, but this host does not".
+func TestAppDomainDetachUnattachedHostIs404(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ const host = "never-attached.example.com"
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
+ if status != http.StatusNotFound {
+ t.Fatalf("detach of an unattached host = %d, want 404:\n%s", status, body)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach-unattached", fx.u("/apps/kass/domains/detach"),
+ url.Values{"host": {host}, "confirm": {host}})
+ if body != strangerBody {
+ t.Fatalf("owner's unattached-host 404 is not byte-identical to the stranger's:\n(owner) %q\n(stranger) %q", body, strangerBody)
+ }
+ claimAbsent(t, fx, host)
+}
+
// TestAppDomainDetachIsOwnerOnly mirrors the attach case: a member gets the
// same byte-identical 404 a stranger would.
func TestAppDomainDetachIsOwnerOnly(t *testing.T) {