Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 01:00 UTC
| 1 | // Copyright 2015 Matthew Holt and The Caddy Authors |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | package integration |
| 16 | |
| 17 | import ( |
| 18 | "fmt" |
| 19 | "io" |
| 20 | "net" |
| 21 | "net/http" |
| 22 | "net/http/fcgi" |
| 23 | "os" |
| 24 | "path/filepath" |
| 25 | "runtime" |
| 26 | "strings" |
| 27 | "testing" |
| 28 | |
| 29 | "github.com/caddyserver/caddy/v2/caddytest" |
| 30 | ) |
| 31 | |
| 32 | // startFakePHPFPM starts a FastCGI responder that reports the SCRIPT_FILENAME it |
| 33 | // was asked to execute, so a test can assert which script Caddy selected. |
| 34 | func startFakePHPFPM(t *testing.T) string { |
| 35 | t.Helper() |
| 36 | |
| 37 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 38 | if err != nil { |
| 39 | t.Fatalf("listening: %v", err) |
| 40 | } |
| 41 | t.Cleanup(func() { ln.Close() }) |
| 42 | |
| 43 | go func() { |
| 44 | _ = fcgi.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 45 | env := fcgi.ProcessEnv(r) |
| 46 | fmt.Fprintf(w, "SCRIPT_FILENAME=%s", env["SCRIPT_FILENAME"]) |
| 47 | })) |
| 48 | }() |
| 49 | |
| 50 | return ln.Addr().String() |
| 51 | } |
| 52 | |
| 53 | // A php_fastcgi route protected by a path matcher must not execute a script the |
| 54 | // matcher never evaluated. Path matchers judge the cleaned request path, so |
| 55 | // /admin.php/../../other.php is matched as /other.php and must not cause |
| 56 | // <root>/admin.php to be executed. |
| 57 | // |
| 58 | // `index off` is used so that php_fastcgi generates no rewrite route at all |
| 59 | // (fastcgi/caddyfile.go skips the redirect and try_files routes entirely). That |
| 60 | // rewrite is otherwise the only step that normalizes r.URL.Path before the |
| 61 | // transport reads it, so without this the test would be satisfied by the rewrite |
| 62 | // rather than by the transport, and would not cover the transport's own |
| 63 | // normalization. |
| 64 | func TestPHPFastCGIProtectedRouteIgnoresDotSegments(t *testing.T) { |
| 65 | root := t.TempDir() |
| 66 | if err := os.WriteFile(filepath.Join(root, "admin.php"), []byte("<?php // protected"), 0o644); err != nil { |
| 67 | t.Fatalf("writing admin.php: %v", err) |
| 68 | } |
| 69 | |
| 70 | backend := startFakePHPFPM(t) |
| 71 | |
| 72 | tester := caddytest.NewTester(t) |
| 73 | tester.InitServer(fmt.Sprintf(`{ |
| 74 | skip_install_trust |
| 75 | admin localhost:2999 |
| 76 | http_port 9080 |
| 77 | https_port 9443 |
| 78 | grace_period 1ns |
| 79 | } |
| 80 | |
| 81 | localhost:9080 { |
| 82 | root * %s |
| 83 | |
| 84 | handle /admin* { |
| 85 | basic_auth { |
| 86 | alice $2a$04$wqmCpxiUKZl83bZKH.Xp0uFvNQoGz3HoBH3IxBwRMrKoCRfTmc3KK |
| 87 | } |
| 88 | php_fastcgi %s { |
| 89 | index off |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | handle { |
| 94 | php_fastcgi %s { |
| 95 | index off |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | `, root, backend, backend), "caddyfile") |
| 100 | |
| 101 | get := func(t *testing.T, uri string, basicAuth bool) (int, string) { |
| 102 | t.Helper() |
| 103 | req, err := http.NewRequest(http.MethodGet, "http://localhost:9080"+uri, nil) |
| 104 | if err != nil { |
| 105 | t.Fatalf("building request: %v", err) |
| 106 | } |
| 107 | if basicAuth { |
| 108 | req.SetBasicAuth("alice", "test-password") |
| 109 | } |
| 110 | resp, err := tester.Client.Do(req) |
| 111 | if err != nil { |
| 112 | t.Fatalf("requesting %q: %v", uri, err) |
| 113 | } |
| 114 | defer resp.Body.Close() |
| 115 | body, err := io.ReadAll(resp.Body) |
| 116 | if err != nil { |
| 117 | t.Fatalf("reading body: %v", err) |
| 118 | } |
| 119 | return resp.StatusCode, string(body) |
| 120 | } |
| 121 | |
| 122 | // The gate works for a direct request... |
| 123 | if code, _ := get(t, "/admin.php", false); code != http.StatusUnauthorized { |
| 124 | t.Fatalf("GET /admin.php without credentials: expected 401, got %d", code) |
| 125 | } |
| 126 | |
| 127 | // ...and valid credentials reach the protected script, which proves the route |
| 128 | // is wired up and the 401 above is the gate rather than a misconfiguration. |
| 129 | code, body := get(t, "/admin.php", true) |
| 130 | if code != http.StatusOK { |
| 131 | t.Fatalf("GET /admin.php with credentials: expected 200, got %d", code) |
| 132 | } |
| 133 | if !strings.HasSuffix(body, filepath.Join(root, "admin.php")) { |
| 134 | t.Fatalf("authenticated request should execute admin.php, got %q", body) |
| 135 | } |
| 136 | |
| 137 | // The bypass: every path matcher sees /other.php, so the /admin* gate does not |
| 138 | // fire. The transport must not then select admin.php. |
| 139 | for _, payload := range []string{ |
| 140 | "/admin.php/../../other.php", |
| 141 | "/admin.php/../other.php", |
| 142 | "/admin.php/..%2f..%2fother.php", |
| 143 | "/admin.php/%2e%2e/other.php", |
| 144 | } { |
| 145 | t.Run(payload, func(t *testing.T) { |
| 146 | _, body := get(t, payload, false) |
| 147 | if strings.Contains(body, "admin.php") { |
| 148 | t.Fatalf("unauthenticated request %q selected the protected script: %s", payload, body) |
| 149 | } |
| 150 | }) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // The php_fastcgi directory redirect must stay on the requesting origin. Its |
| 155 | // matcher judges the cleaned request path, so /%2Fevil.com%2F..%2Fsub is matched |
| 156 | // as /sub and resolves to <root>/sub/index.php, but the Location header used to |
| 157 | // be built from the raw original path and came out as //evil.com/../sub/, which |
| 158 | // browsers treat as a scheme-relative URL to another host. The relative |
| 159 | // reference is built from the escaped original segment, so names with reserved |
| 160 | // or non-ASCII characters must still round-trip to the same directory. |
| 161 | func TestPHPFastCGIDirectoryRedirectStaysOnOrigin(t *testing.T) { |
| 162 | dirs := []string{"sub", "a b", "a#b", "50%", "a;b", "café", "x/sub"} |
| 163 | if runtime.GOOS != "windows" { |
| 164 | dirs = append(dirs, "a?b", "a:b") |
| 165 | } |
| 166 | root := t.TempDir() |
| 167 | for _, dir := range dirs { |
| 168 | if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil { |
| 169 | t.Fatalf("creating %s: %v", dir, err) |
| 170 | } |
| 171 | if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(dir), "index.php"), []byte("<?php // index"), 0o644); err != nil { |
| 172 | t.Fatalf("writing %s/index.php: %v", dir, err) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | tester := caddytest.NewTester(t) |
| 177 | tester.InitServer(fmt.Sprintf(`{ |
| 178 | skip_install_trust |
| 179 | admin localhost:2999 |
| 180 | http_port 9080 |
| 181 | https_port 9443 |
| 182 | grace_period 1ns |
| 183 | } |
| 184 | |
| 185 | localhost:9080 { |
| 186 | root * %s |
| 187 | php_fastcgi 127.0.0.1:9 |
| 188 | } |
| 189 | `, root), "caddyfile") |
| 190 | |
| 191 | tests := []struct { |
| 192 | uri string |
| 193 | want string |
| 194 | }{ |
| 195 | {uri: "/sub", want: "/sub/"}, |
| 196 | {uri: "/sub?a=1", want: "/sub/?a=1"}, |
| 197 | {uri: "/%2Fevil.com%2F..%2Fsub", want: "/%2Fevil.com%2F..%2Fsub/"}, |
| 198 | {uri: "/%5Cevil.com%2F..%2Fsub", want: "/%5Cevil.com%2F..%2Fsub/"}, |
| 199 | {uri: "/a%20b", want: "/a%20b/"}, |
| 200 | {uri: "/a%23b", want: "/a%23b/"}, |
| 201 | {uri: "/50%25", want: "/50%25/"}, |
| 202 | {uri: "/a;b", want: "/a;b/"}, |
| 203 | {uri: "/caf%C3%A9", want: "/caf%C3%A9/"}, |
| 204 | {uri: "/x%2Fsub", want: "/x%2Fsub/"}, |
| 205 | } |
| 206 | if runtime.GOOS != "windows" { |
| 207 | tests = append(tests, |
| 208 | struct{ uri, want string }{uri: "/a%3Fb", want: "/a%3Fb/"}, |
| 209 | struct{ uri, want string }{uri: "/a:b", want: "/a:b/"}, |
| 210 | ) |
| 211 | } |
| 212 | for _, tc := range tests { |
| 213 | resp := tester.AssertRedirect("http://localhost:9080"+tc.uri, "http://localhost:9080"+tc.want, http.StatusPermanentRedirect) |
| 214 | if resp == nil { |
| 215 | continue |
| 216 | } |
| 217 | loc := resp.Header.Get("Location") |
| 218 | // a Location beginning with // or /\ is scheme-relative to browsers |
| 219 | if strings.HasPrefix(loc, "//") || strings.HasPrefix(loc, `/\`) { |
| 220 | t.Errorf("requesting %q: Location %q is not origin-relative", tc.uri, loc) |
| 221 | } |
| 222 | for i := 0; i < len(loc); i++ { |
| 223 | if loc[i] < 0x21 || loc[i] > 0x7e { |
| 224 | t.Errorf("requesting %q: Location %q contains an unescaped byte %#x", tc.uri, loc, loc[i]) |
| 225 | break |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // Without a PHP front controller, php_fastcgi must leave static files and |
| 232 | // directory indexes to file_server instead of sending them to a missing |
| 233 | // index.php (https://github.com/caddyserver/caddy/issues/8367). |
| 234 | func TestPHPFastCGIWithoutIndexPHP(t *testing.T) { |
| 235 | root := t.TempDir() |
| 236 | for name, content := range map[string]string{ |
| 237 | "index.html": "home", |
| 238 | "style.css": "css", |
| 239 | "about/index.html": "about", |
| 240 | } { |
| 241 | file := filepath.Join(root, filepath.FromSlash(name)) |
| 242 | if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | if err := os.WriteFile(file, []byte(content), 0o644); err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | backend := startFakePHPFPM(t) |
| 251 | |
| 252 | tester := caddytest.NewTester(t) |
| 253 | tester.InitServer(fmt.Sprintf(`{ |
| 254 | skip_install_trust |
| 255 | admin localhost:2999 |
| 256 | http_port 9080 |
| 257 | https_port 9443 |
| 258 | grace_period 1ns |
| 259 | } |
| 260 | |
| 261 | localhost:9080 { |
| 262 | root * %s |
| 263 | php_fastcgi %s |
| 264 | file_server |
| 265 | } |
| 266 | `, root, backend), "caddyfile") |
| 267 | |
| 268 | tester.AssertGetResponse("http://localhost:9080/", http.StatusOK, "home") |
| 269 | tester.AssertGetResponse("http://localhost:9080/about/", http.StatusOK, "about") |
| 270 | tester.AssertGetResponse("http://localhost:9080/style.css", http.StatusOK, "css") |
| 271 | tester.AssertGetResponse("http://localhost:9080/missing", http.StatusNotFound, "") |
| 272 | |
| 273 | // a front controller added later handles the paths that match no file |
| 274 | if err := os.WriteFile(filepath.Join(root, "index.php"), []byte("<?php // front controller"), 0o644); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | tester.AssertGetResponse("http://localhost:9080/missing", http.StatusOK, "SCRIPT_FILENAME="+filepath.Join(root, "index.php")) |
| 278 | tester.AssertGetResponse("http://localhost:9080/style.css", http.StatusOK, "css") |
| 279 | } |