| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // The "not here YET" answer (#174). |
| 11 | |
| 12 | // pendingEdge is an edge serving one route, with the pending answer armed |
| 13 | // for go.tito.example and apps.example. |
| 14 | func pendingEdge(t *testing.T, fallback string) *Edge { |
| 15 | t.Helper() |
| 16 | e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { |
| 17 | w.WriteHeader(http.StatusOK) |
| 18 | }) |
| 19 | e.fallbackURL = fallback |
| 20 | e.setPlatformDomains(platformDomainsFromEnv(func(k string) string { |
| 21 | return map[string]string{ |
| 22 | "CARLOS_WILDCARD_DOMAINS": "go.tito.example, GO.TITO.EXAMPLE", |
| 23 | "PLATFORM_APPS_DOMAIN": "apps.example.", |
| 24 | }[k] |
| 25 | })) |
| 26 | return e |
| 27 | } |
| 28 | |
| 29 | func get(e *Edge, method, url string) *httptest.ResponseRecorder { |
| 30 | rec := httptest.NewRecorder() |
| 31 | e.ServeHTTP(rec, httptest.NewRequest(method, url, nil)) |
| 32 | return rec |
| 33 | } |
| 34 | |
| 35 | // TestPendingHostUnderAPlatformDomainIs503 is the headline: a host the edge |
| 36 | // has not heard of yet, under a platform domain, gets a retryable answer |
| 37 | // rather than a dead end. |
| 38 | func TestPendingHostUnderAPlatformDomainIs503(t *testing.T) { |
| 39 | e := pendingEdge(t, "") |
| 40 | for _, url := range []string{ |
| 41 | "https://sb-andyet.go.tito.example/dashboard", |
| 42 | "https://acct.apps.example/", |
| 43 | "https://app.acct.apps.example/x?y=1", |
| 44 | } { |
| 45 | rec := get(e, "GET", url) |
| 46 | if rec.Code != http.StatusServiceUnavailable { |
| 47 | t.Fatalf("%s: status %d, want 503", url, rec.Code) |
| 48 | } |
| 49 | if got := rec.Header().Get("Retry-After"); got != pendingRetryAfter { |
| 50 | t.Fatalf("%s: Retry-After %q, want %q", url, got, pendingRetryAfter) |
| 51 | } |
| 52 | if got := rec.Header().Get("Cache-Control"); got != "no-store" { |
| 53 | t.Fatalf("%s: Cache-Control %q, want no-store", url, got) |
| 54 | } |
| 55 | body := rec.Body.String() |
| 56 | if !strings.Contains(body, pendingHeading) || !strings.Contains(body, "location.replace(location.href)") { |
| 57 | t.Fatalf("%s: body is not the retrying pending page:\n%s", url, body) |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // TestPendingPageNeverEchoesTheRequest — the host, path and query are |
| 63 | // attacker-chosen for an unknown host; none of them may reach the page. |
| 64 | func TestPendingPageNeverEchoesTheRequest(t *testing.T) { |
| 65 | e := pendingEdge(t, "") |
| 66 | rec := get(e, "GET", "https://xss-probe.go.tito.example/%3Cscript%3E?q=%3Cimg%3E") |
| 67 | body := rec.Body.String() |
| 68 | for _, s := range []string{"xss-probe", "<script>alert", "%3C", "<img"} { |
| 69 | if strings.Contains(body, s) { |
| 70 | t.Fatalf("the pending page echoed %q from the request:\n%s", s, body) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // TestPendingHostHEADHasNoBody — the headers alone. |
| 76 | func TestPendingHostHEADHasNoBody(t *testing.T) { |
| 77 | e := pendingEdge(t, "") |
| 78 | rec := get(e, "HEAD", "https://sb-new.go.tito.example/") |
| 79 | if rec.Code != http.StatusServiceUnavailable || rec.Body.Len() != 0 { |
| 80 | t.Fatalf("HEAD: status %d with a %d-byte body, want 503 and none", rec.Code, rec.Body.Len()) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // TestUnknownHostOutsidePlatformDomainsIsUnchanged — a customer domain, and a |
| 85 | // platform domain's own apex, keep the old answers exactly: 404 without a |
| 86 | // fallback, the 302 with one. |
| 87 | func TestUnknownHostOutsidePlatformDomainsIsUnchanged(t *testing.T) { |
| 88 | for _, tc := range []struct { |
| 89 | fallback string |
| 90 | want int |
| 91 | }{{"", http.StatusNotFound}, {"https://www.example.com/", http.StatusFound}} { |
| 92 | e := pendingEdge(t, tc.fallback) |
| 93 | for _, url := range []string{ |
| 94 | "https://customer.example.org/", |
| 95 | "https://apps.example/", // the apex itself (go.tito.example is a served route here) |
| 96 | "https://evilgo.tito.example/", // not a label boundary of go.tito.example |
| 97 | } { |
| 98 | if rec := get(e, "GET", url); rec.Code != tc.want { |
| 99 | t.Fatalf("fallback %q, %s: status %d, want %d", tc.fallback, url, rec.Code, tc.want) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // TestPendingPageGivesUpToTheFallback — an edge with a fallback URL still |
| 106 | // sends a mistyped or retired platform subdomain there, after the retry |
| 107 | // window, so the fallback keeps doing its job. The URL is embedded as a JS |
| 108 | // string, escaped. |
| 109 | func TestPendingPageGivesUpToTheFallback(t *testing.T) { |
| 110 | e := pendingEdge(t, `https://www.example.com/?a=1&b="x"</script>`) |
| 111 | body := get(e, "GET", "https://typo.go.tito.example/").Body.String() |
| 112 | if !strings.Contains(body, "location.replace(f)") { |
| 113 | t.Fatalf("the page does not go to the fallback when it gives up:\n%s", body) |
| 114 | } |
| 115 | if strings.Count(body, "</script>") != 1 { |
| 116 | t.Fatalf("the fallback URL broke out of the script element:\n%s", body) |
| 117 | } |
| 118 | if !strings.Contains(body, `"https://www.example.com/?a=1`+"\\u0026"+`b=\"x\"`+"\\u003c"+`/script`+"\\u003e"+`"`) { |
| 119 | t.Fatalf("the fallback URL is not embedded as an escaped JS string:\n%s", body) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // TestPendingAnswerIsOffWithoutPlatformDomains — New alone (no env) arms |
| 124 | // nothing: every unknown host is the plain 404, as before #174. |
| 125 | func TestPendingAnswerIsOffWithoutPlatformDomains(t *testing.T) { |
| 126 | e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {}) |
| 127 | if rec := get(e, "GET", "https://sb-new.go.tito.example/"); rec.Code != http.StatusNotFound { |
| 128 | t.Fatalf("status %d, want 404 with no platform domains configured", rec.Code) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // TestKnownHostUnderAPlatformDomainIsServed — the pending answer is only |
| 133 | // ever the miss branch. |
| 134 | func TestKnownHostUnderAPlatformDomainIsServed(t *testing.T) { |
| 135 | e := pendingEdge(t, "") |
| 136 | if rec := get(e, "GET", "https://acme.go.tito.example/"); rec.Code != http.StatusOK { |
| 137 | t.Fatalf("a served host under a platform domain got %d", rec.Code) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // TestPendingHostOnPlainHTTP — :80 for an unknown platform-domain host gets |
| 142 | // the same answer rather than a redirect into a handshake. |
| 143 | func TestPendingHostOnPlainHTTP(t *testing.T) { |
| 144 | e := pendingEdge(t, "") |
| 145 | rec := httptest.NewRecorder() |
| 146 | e.httpFallbackHandler().ServeHTTP(rec, httptest.NewRequest("GET", "http://sb-new.go.tito.example/", nil)) |
| 147 | if rec.Code != http.StatusServiceUnavailable { |
| 148 | t.Fatalf(":80 status %d, want 503", rec.Code) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // TestPendingAnswerNeedsTheRouteTableForm — routing matches r.Host byte for |
| 153 | // byte, so an uppercase or trailing-dot spelling of a platform host can miss |
| 154 | // even when the host is served. Those keep the plain unknown-host answer |
| 155 | // rather than 90 seconds of "still being set up". |
| 156 | func TestPendingAnswerNeedsTheRouteTableForm(t *testing.T) { |
| 157 | e := pendingEdge(t, "") |
| 158 | for _, host := range []string{"Acme.go.tito.example", "acme.go.tito.example.", "SB-NEW.GO.TITO.EXAMPLE"} { |
| 159 | req := httptest.NewRequest("GET", "https://x/", nil) |
| 160 | req.Host = host |
| 161 | rec := httptest.NewRecorder() |
| 162 | e.ServeHTTP(rec, req) |
| 163 | if rec.Code != http.StatusNotFound { |
| 164 | t.Fatalf("%s: status %d, want the plain 404", host, rec.Code) |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |