From 9f92a9c86dfcce7978ae08fe0a11f9ff988e39b8 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sat, 25 Jun 2022 12:10:30 +0100 Subject: [PATCH 01/11] basic server --- http-auth/README.md | 16 +++++++++++++++- http-auth/go.mod | 3 +++ http-auth/main.go | 11 +++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 http-auth/go.mod create mode 100644 http-auth/main.go diff --git a/http-auth/README.md b/http-auth/README.md index 7c04fe45b..689618f84 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -19,4 +19,18 @@ Learning objectives: ## Project -TODO +- `go mod init http-auth` +- create empty main package and main function +- `go run .` +- `import "net/http"` +- Basic server: + +```go +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, world")) + }) + + http.ListenAndServe(":8080", nil) +} +``` diff --git a/http-auth/go.mod b/http-auth/go.mod new file mode 100644 index 000000000..0e75eede3 --- /dev/null +++ b/http-auth/go.mod @@ -0,0 +1,3 @@ +module http-auth + +go 1.18 diff --git a/http-auth/main.go b/http-auth/main.go new file mode 100644 index 000000000..e710b17db --- /dev/null +++ b/http-auth/main.go @@ -0,0 +1,11 @@ +package main + +import "net/http" + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, world")) + }) + + http.ListenAndServe(":8080", nil) +} From 966a19a3f8a37842b37c6d9ad757871b9c4e06fa Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sat, 25 Jun 2022 12:18:27 +0100 Subject: [PATCH 02/11] routes with statuses --- http-auth/README.md | 30 ++++++++++++++++++++++++++++++ http-auth/main.go | 12 ++++++++++++ 2 files changed, 42 insertions(+) diff --git a/http-auth/README.md b/http-auth/README.md index 689618f84..442c4a1ea 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -34,3 +34,33 @@ func main() { http.ListenAndServe(":8080", nil) } ``` + +- User `curl` to interact +- Add handlers such that the following URLs and responses work. Use `http.NotFoundHandler()` + +``` +> curl -i http://localhost:8080/500 +HTTP/1.1 500 Internal Server Error +Date: Sat, 25 Jun 2022 11:16:30 GMT +Content-Length: 21 +Content-Type: text/plain; charset=utf-8 + +Internal server error + +> curl -i http://localhost:8080/200 +HTTP/1.1 200 OK +Date: Sat, 25 Jun 2022 11:17:17 GMT +Content-Length: 3 +Content-Type: text/plain; charset=utf-8 + +200 + +> curl -i http://localhost:8080/404 +HTTP/1.1 404 Not Found +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Sat, 25 Jun 2022 11:17:29 GMT +Content-Length: 19 + +404 page not found +``` diff --git a/http-auth/main.go b/http-auth/main.go index e710b17db..6a3e02069 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -7,5 +7,17 @@ func main() { w.Write([]byte("Hello, world")) }) + http.HandleFunc("/200", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("200")) + }) + + http.Handle("/404", http.NotFoundHandler()) + + http.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + }) + http.ListenAndServe(":8080", nil) } From 3ce0cee7b95179c9bde56ec113d48975f3e9fdcb Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 26 Jun 2022 12:03:52 +0100 Subject: [PATCH 03/11] basic POST implementation --- http-auth/main.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/http-auth/main.go b/http-auth/main.go index 6a3e02069..ee07c572c 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -1,10 +1,30 @@ package main -import "net/http" +import ( + "io" + "net/http" +) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello, world")) + // Indicate that we are sending back HTML + w.Header().Add("Content-Type", "text/html") + // Write the doctype and opening tag regardless of method + w.Write([]byte("")) + // If the request is POSTing data, return what they sent back + if r.Method == "POST" { + // The request (r) body is an io.Reader and the response (w) is a writer + // so we can stream one directly into the other in chunks. + // We ignore the output of io.Copy and just handle the error. + if _, err := io.Copy(w, r.Body); err != nil { + // In the case of an error in this copying process, return a server error + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + } + } else { + // In all other cases, just say hello + w.Write([]byte("Hello, world")) + } }) http.HandleFunc("/200", func(w http.ResponseWriter, r *http.Request) { From d342a4a12d4330d27c70ea1d88776a2c37407b6f Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 24 Jul 2022 10:44:52 +0100 Subject: [PATCH 04/11] HTML and POST examples --- http-auth/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/http-auth/README.md b/http-auth/README.md index 442c4a1ea..d00e80289 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -64,3 +64,29 @@ Content-Length: 19 404 page not found ``` + +- Make the index page at `/` returns some HTML to a `GET` request + +``` +curl -i http://localhost:8080/ +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 09:42:30 GMT +Content-Length: 42 + +Hello, world% +``` + +- Make the index page accept `POST` requests with some HTML, and return that HTML: + +``` +curl -i -d "Hi" http://localhost:8080/ +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 09:43:20 GMT +Content-Length: 32 + +Hi +``` + +- Ensure you've got error handling in the handler function From 7f37c223ae4c0d215e8b2943444a941ef608a529 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 24 Jul 2022 10:56:35 +0100 Subject: [PATCH 05/11] basic query params --- http-auth/README.md | 22 ++++++++++++++++++++-- http-auth/main.go | 11 +++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/http-auth/README.md b/http-auth/README.md index d00e80289..9394042c5 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -68,7 +68,7 @@ Content-Length: 19 - Make the index page at `/` returns some HTML to a `GET` request ``` -curl -i http://localhost:8080/ +> curl -i http://localhost:8080/ HTTP/1.1 200 OK Content-Type: text/html Date: Sun, 24 Jul 2022 09:42:30 GMT @@ -80,7 +80,7 @@ Content-Length: 42 - Make the index page accept `POST` requests with some HTML, and return that HTML: ``` -curl -i -d "Hi" http://localhost:8080/ +> curl -i -d "Hi" http://localhost:8080/ HTTP/1.1 200 OK Content-Type: text/html Date: Sun, 24 Jul 2022 09:43:20 GMT @@ -90,3 +90,21 @@ Content-Length: 32 ``` - Ensure you've got error handling in the handler function + +- Make the handler at `/` output the query parameters as a list. Having the output spaced over multiple lines is optional, but done here for readability. + +``` +> curl -i http://localhost:8080\?foo=bar +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 09:55:33 GMT +Content-Length: 96 + + + +Hello, world +

Query parameters: +

    +
  • foo: [bar]
  • +
+``` diff --git a/http-auth/main.go b/http-auth/main.go index ee07c572c..5cf97d61f 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "net/http" ) @@ -10,7 +11,7 @@ func main() { // Indicate that we are sending back HTML w.Header().Add("Content-Type", "text/html") // Write the doctype and opening tag regardless of method - w.Write([]byte("")) + w.Write([]byte("\n\n")) // If the request is POSTing data, return what they sent back if r.Method == "POST" { // The request (r) body is an io.Reader and the response (w) is a writer @@ -23,7 +24,13 @@ func main() { } } else { // In all other cases, just say hello - w.Write([]byte("Hello, world")) + w.Write([]byte("Hello, world\n")) + w.Write([]byte("

Query parameters:\n

    \n")) + for k, v := range r.URL.Query() { + w.Write([]byte(fmt.Sprintf("
  • %s: %s
  • \n", k, v))) + } + w.Write([]byte("
")) + } }) From e6eb216a827d14d29c22882cbd6e27fec7521897 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 24 Jul 2022 11:12:55 +0100 Subject: [PATCH 06/11] escape the query and post body values --- http-auth/README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++ http-auth/main.go | 27 ++++++++++++++++++------ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/http-auth/README.md b/http-auth/README.md index 9394042c5..ef8c4bd8e 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -108,3 +108,54 @@ Content-Length: 96
  • foo: [bar]
  • ``` + +- Try putting some HTML into the query params or body to see that it is interpreted as HTML: + +``` +> curl -i http://localhost:8080\?foo=\bar\ +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 09:57:20 GMT +Content-Length: 113 + + + +Hello, world +

    Query parameters: +

      +
    • foo: [bar]
    • +
    +``` + +This isn't good! This kind of thing can lead to security issues. Search for "XSS attack" to find out more. Let's fix it. + +- "Escape" the string any time you take some input (data in `POST` or query parameters) and output it back: + +``` +> curl -i http://localhost:8080\?foo=\bar\ +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 10:08:08 GMT +Content-Length: 125 + + + +Hello, world +

    Query parameters: +

      +
    • foo: [<strong>bar</strong>]
    • +
    +``` + +``` +> curl -i -d "Hi" http://localhost:8080/ +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 10:08:21 GMT +Content-Length: 46 + + + +<em>Hi</em> +``` diff --git a/http-auth/main.go b/http-auth/main.go index 5cf97d61f..1eb622917 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -2,8 +2,10 @@ package main import ( "fmt" + "html" "io" "net/http" + "strings" ) func main() { @@ -14,20 +16,33 @@ func main() { w.Write([]byte("\n\n")) // If the request is POSTing data, return what they sent back if r.Method == "POST" { - // The request (r) body is an io.Reader and the response (w) is a writer - // so we can stream one directly into the other in chunks. - // We ignore the output of io.Copy and just handle the error. - if _, err := io.Copy(w, r.Body); err != nil { + // The request (r) body is an io.Reader so we can copy it into the + // string builder and handle errors + body := new(strings.Builder) + if _, err := io.Copy(body, r.Body); err != nil { // In the case of an error in this copying process, return a server error w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal server error")) } + // Write the body back to the requester in a safe way + w.Write([]byte(html.EscapeString(body.String()))) } else { // In all other cases, just say hello w.Write([]byte("Hello, world\n")) w.Write([]byte("

    Query parameters:\n

      \n")) - for k, v := range r.URL.Query() { - w.Write([]byte(fmt.Sprintf("
    • %s: %s
    • \n", k, v))) + // Query parameters are available as a Values map[string][]string + // https://pkg.go.dev/net/url#Values + for k, vs := range r.URL.Query() { + // As we're sending the query parameters straight back, we need to escape them. + // Each value is a list, supporting query params like ?color=red&color=blue + // so we need to iterate through each query parameter value and escape the string + escaped_vs := make([]string, len(vs)) + for i, v := range vs { + escaped_vs[i] = html.EscapeString(v) + } + // We can now write a list item, escaping the key and printing the escaped values list + // TODO: is the use of %s here unsafe? https://pkg.go.dev/fmt + w.Write([]byte(fmt.Sprintf("
    • %s: %s
    • \n", html.EscapeString(k), escaped_vs))) } w.Write([]byte("
    ")) From 80c50838893b9bba21e40b7deb21ef8ceb192fa8 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 24 Jul 2022 15:13:31 +0100 Subject: [PATCH 07/11] basic auth --- http-auth/README.md | 22 ++++++++++++++++++++++ http-auth/main.go | 16 ++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/http-auth/README.md b/http-auth/README.md index ef8c4bd8e..d467e8764 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -159,3 +159,25 @@ Content-Length: 46 <em>Hi</em> ``` + +- Add an endpoint `/authenticated` that requires the use of HTTP Basic auth. It should return a `401 Unauthorized` status code with a `WWW-Authenticate` header if basic auth is not present or does not match a username and password of your choice. Once Basic Auth is provided, it should respond successful! + +``` +> curl -i http://localhost:8080/authenticated +HTTP/1.1 401 Unauthorized +Www-Authenticate: Basic realm="localhost", charset="UTF-8" +Date: Sun, 24 Jul 2022 14:12:35 GMT +Content-Length: 0 +``` + +``` +> curl -i http://localhost:8080/authenticated -H 'Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=' +HTTP/1.1 200 OK +Content-Type: text/html +Date: Sun, 24 Jul 2022 14:13:04 GMT +Content-Length: 38 + + + +Hello username! +``` diff --git a/http-auth/main.go b/http-auth/main.go index 1eb622917..d40110c95 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -8,6 +8,10 @@ import ( "strings" ) +func authOk(user string, pass string) bool { + return user == "username" && pass == "password" +} + func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Indicate that we are sending back HTML @@ -54,6 +58,18 @@ func main() { w.Write([]byte("200")) }) + http.HandleFunc("/authenticated", func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || !authOk(username, password) { + w.Header().Add("WWW-Authenticate", "Basic realm=\"localhost\", charset=\"UTF-8\"") + w.WriteHeader(http.StatusUnauthorized) + } else { + w.Header().Add("Content-Type", "text/html") + w.Write([]byte("\n\n")) + w.Write([]byte(fmt.Sprintf("Hello %s!", html.EscapeString(username)))) + } + }) + http.Handle("/404", http.NotFoundHandler()) http.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) { From 914af6365a9a99b7cbf8ef31204bc5ae75b564e7 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 24 Jul 2022 18:13:54 +0100 Subject: [PATCH 08/11] remove secrets from the code, use env --- http-auth/README.md | 8 ++++++++ http-auth/main.go | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/http-auth/README.md b/http-auth/README.md index d467e8764..533ac6b51 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -181,3 +181,11 @@ Content-Length: 38 Hello username! ``` + +You can generate the `dXNl...` text [using this website](https://opinionatedgeek.com/Codecs/Base64Encoder). This is "base64 encoded" which you can search for to find a bit more about. Enter `username:password` to get `dXNlcm5hbWU6cGFzc3dvcmQ=`. + +- It's not a good idea to put secrets like passwords into code. So remove any hard-coded usernames and passwords for basic auth, and use `os.Getenv(...)` so that this works: + +``` +> AUTH_USERNAME=admin AUTH_PASSWORD=long-memorable-password go run . +``` diff --git a/http-auth/main.go b/http-auth/main.go index d40110c95..9322f5c89 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -5,11 +5,12 @@ import ( "html" "io" "net/http" + "os" "strings" ) func authOk(user string, pass string) bool { - return user == "username" && pass == "password" + return user == os.Getenv("AUTH_USERNAME") && pass == os.Getenv("AUTH_PASSWORD") } func main() { From d949b38194bf0929261df1d0f98a8f44df0dd3a8 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 24 Jul 2022 19:36:51 +0100 Subject: [PATCH 09/11] rate limiting --- http-auth/README.md | 129 ++++++++++++++++++++++++++++++++++++++++++++ http-auth/go.mod | 2 + http-auth/go.sum | 2 + http-auth/main.go | 24 +++++++++ 4 files changed, 157 insertions(+) create mode 100644 http-auth/go.sum diff --git a/http-auth/README.md b/http-auth/README.md index 533ac6b51..2a4ed7e7d 100644 --- a/http-auth/README.md +++ b/http-auth/README.md @@ -189,3 +189,132 @@ You can generate the `dXNl...` text [using this website](https://opinionatedgeek ``` > AUTH_USERNAME=admin AUTH_PASSWORD=long-memorable-password go run . ``` + +- [Follow this guide](https://www.datadoghq.com/blog/apachebench/) to install and use ApacheBench, which will test to see how many requests your server can handle + +``` +> ab -n 10000 -c 100 http://localhost:8080/ + +This is ApacheBench, Version 2.3 <$Revision: 1879490 $> +Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ +Licensed to The Apache Software Foundation, http://www.apache.org/ + +Benchmarking localhost (be patient) +Completed 1000 requests +Completed 2000 requests +Completed 3000 requests +Completed 4000 requests +Completed 5000 requests +Completed 6000 requests +Completed 7000 requests +Completed 8000 requests +Completed 9000 requests +Completed 10000 requests +Finished 10000 requests + + +Server Software: +Server Hostname: localhost +Server Port: 8080 + +Document Path: / +Document Length: 76 bytes + +Concurrency Level: 100 +Time taken for tests: 0.779 seconds +Complete requests: 10000 +Failed requests: 0 +Total transferred: 1770000 bytes +HTML transferred: 760000 bytes +Requests per second: 12837.71 [#/sec] (mean) +Time per request: 7.790 [ms] (mean) +Time per request: 0.078 [ms] (mean, across all concurrent requests) +Transfer rate: 2219.02 [Kbytes/sec] received + +Connection Times (ms) + min mean[+/-sd] median max +Connect: 0 4 3.2 3 49 +Processing: 1 4 3.2 4 49 +Waiting: 0 4 3.1 4 49 +Total: 5 8 4.5 7 53 + +Percentage of the requests served within a certain time (ms) + 50% 7 + 66% 8 + 75% 8 + 80% 8 + 90% 8 + 95% 9 + 98% 10 + 99% 11 + 100% 53 (longest request) +``` + +- It's better to protect your server from being asked to handle too many requests than to have it fall over! So use the `rate` library to reject excess requests (> X per second) with a `503 Service Unavailable` error on a `/limited` endpoint. + +``` +> go get -u golang.org/x/time +``` + +You will need to import the module: + +```go +import "golang.org/x/time/rate" +``` + +Then create a limiter: + +```go +lim := rate.NewLimiter(100, 30) +``` + +And use it: + +```go +if lim.Allow() { + // Respond as normal! +} else { + // Respond with an error +} +``` + +If it is working, you will see `Non-2xx responses` and `Failed requests` in your ApacheBench output: + +``` +> ab -n 100 -c 100 http://localhost:8080/limited +... + +Document Path: /limited +Document Length: 35 bytes + +Concurrency Level: 100 +Time taken for tests: 0.006 seconds +Complete requests: 100 +Failed requests: 70 <----- HERE! + (Connect: 0, Receive: 0, Length: 70, Exceptions: 0) +Non-2xx responses: 70 <----- HERE! +Total transferred: 17170 bytes +HTML transferred: 2450 bytes +Requests per second: 15544.85 [#/sec] (mean) +Time per request: 6.433 [ms] (mean) +Time per request: 0.064 [ms] (mean, across all concurrent requests) +Transfer rate: 2606.49 [Kbytes/sec] received + +Connection Times (ms) + min mean[+/-sd] median max +Connect: 0 2 0.7 2 4 +Processing: 1 1 0.3 1 4 +Waiting: 0 1 0.2 1 1 +Total: 2 4 0.7 4 5 + +Percentage of the requests served within a certain time (ms) + 50% 4 + 66% 4 + 75% 4 + 80% 4 + 90% 5 + 95% 5 + 98% 5 + 99% 5 + 100% 5 (longest request) +``` diff --git a/http-auth/go.mod b/http-auth/go.mod index 0e75eede3..21cf751d3 100644 --- a/http-auth/go.mod +++ b/http-auth/go.mod @@ -1,3 +1,5 @@ module http-auth go 1.18 + +require golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect diff --git a/http-auth/go.sum b/http-auth/go.sum new file mode 100644 index 000000000..00176fd22 --- /dev/null +++ b/http-auth/go.sum @@ -0,0 +1,2 @@ +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/http-auth/main.go b/http-auth/main.go index 9322f5c89..5908c838d 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -7,12 +7,27 @@ import ( "net/http" "os" "strings" + + "golang.org/x/time/rate" ) func authOk(user string, pass string) bool { return user == os.Getenv("AUTH_USERNAME") && pass == os.Getenv("AUTH_PASSWORD") } +// Take a rate.Limiter instance and a http.HandlerFunc and return another http.HandlerFunc that +// checks the rate limiter using `Allow()` before calling the supplied handler. If the request +// is not allowed by the limiter, a `503 Service Unavailable` Error is returned. +func rateLimit(lim *rate.Limiter, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if lim.Allow() { + next.ServeHTTP(w, r) + } else { + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + } + }) +} + func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Indicate that we are sending back HTML @@ -71,6 +86,15 @@ func main() { } }) + lim := rate.NewLimiter(100, 30) + + // This endpoint is rate limited by `lim`. The handler function is wrapped by `rateLimit`, which + // will call it if the request is allowed under the rate limit, or automatically return a 503 + http.HandleFunc("/limited", rateLimit(lim, func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + w.Write([]byte("\n\nHello world!")) + })) + http.Handle("/404", http.NotFoundHandler()) http.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) { From 4c0fd8f312624c8de5dbc5130d4c71af47b65a58 Mon Sep 17 00:00:00 2001 From: Daniel Wagner-Hall Date: Wed, 3 Aug 2022 21:40:37 +0100 Subject: [PATCH 10/11] Tweaks to http-auth implementation --- http-auth/main.go | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/http-auth/main.go b/http-auth/main.go index 5908c838d..9f129c276 100644 --- a/http-auth/main.go +++ b/http-auth/main.go @@ -4,6 +4,7 @@ import ( "fmt" "html" "io" + "log" "net/http" "os" "strings" @@ -18,9 +19,9 @@ func authOk(user string, pass string) bool { // Take a rate.Limiter instance and a http.HandlerFunc and return another http.HandlerFunc that // checks the rate limiter using `Allow()` before calling the supplied handler. If the request // is not allowed by the limiter, a `503 Service Unavailable` Error is returned. -func rateLimit(lim *rate.Limiter, next http.HandlerFunc) http.HandlerFunc { +func rateLimit(limiter *rate.Limiter, next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if lim.Allow() { + if limiter.Allow() { next.ServeHTTP(w, r) } else { http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) @@ -28,12 +29,16 @@ func rateLimit(lim *rate.Limiter, next http.HandlerFunc) http.HandlerFunc { }) } +// writeStartOfHTML is a function we can call from both the POST and GET path to start off the HTML response. +func writeStartOfHTML(w http.ResponseWriter) { + // Indicate that we are sending back HTML + w.Header().Add("Content-Type", "text/html") + // Write the doctype and opening tag + w.Write([]byte("\n\n")) +} + func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Indicate that we are sending back HTML - w.Header().Add("Content-Type", "text/html") - // Write the doctype and opening tag regardless of method - w.Write([]byte("\n\n")) // If the request is POSTing data, return what they sent back if r.Method == "POST" { // The request (r) body is an io.Reader so we can copy it into the @@ -41,12 +46,16 @@ func main() { body := new(strings.Builder) if _, err := io.Copy(body, r.Body); err != nil { // In the case of an error in this copying process, return a server error + log.Printf("Error copying request body: %v", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal server error")) + return } + writeStartOfHTML(w) // Write the body back to the requester in a safe way w.Write([]byte(html.EscapeString(body.String()))) } else { + writeStartOfHTML(w) // In all other cases, just say hello w.Write([]byte("Hello, world\n")) w.Write([]byte("

    Query parameters:\n

      \n")) @@ -56,13 +65,12 @@ func main() { // As we're sending the query parameters straight back, we need to escape them. // Each value is a list, supporting query params like ?color=red&color=blue // so we need to iterate through each query parameter value and escape the string - escaped_vs := make([]string, len(vs)) - for i, v := range vs { - escaped_vs[i] = html.EscapeString(v) + escapedVs := make([]string, 0, len(vs)) + for _, v := range vs { + escapedVs = append(escapedVs, html.EscapeString(v)) } // We can now write a list item, escaping the key and printing the escaped values list - // TODO: is the use of %s here unsafe? https://pkg.go.dev/fmt - w.Write([]byte(fmt.Sprintf("
    • %s: %s
    • \n", html.EscapeString(k), escaped_vs))) + w.Write([]byte(fmt.Sprintf("
    • %s: [%s]
    • \n", html.EscapeString(k), strings.Join(escapedVs, ", ")))) } w.Write([]byte("
    ")) @@ -86,11 +94,12 @@ func main() { } }) - lim := rate.NewLimiter(100, 30) + limiter := rate.NewLimiter(100, 30) - // This endpoint is rate limited by `lim`. The handler function is wrapped by `rateLimit`, which - // will call it if the request is allowed under the rate limit, or automatically return a 503 - http.HandleFunc("/limited", rateLimit(lim, func(w http.ResponseWriter, r *http.Request) { + // This endpoint is rate limited by `limiter`. The handler function is wrapped by `rateLimit`, + // which will call it if the request is allowed under the rate limit, or automatically return + // a 503. + http.HandleFunc("/limited", rateLimit(limiter, func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "text/html") w.Write([]byte("\n\nHello world!")) })) @@ -102,5 +111,8 @@ func main() { w.Write([]byte("Internal server error")) }) - http.ListenAndServe(":8080", nil) + err := http.ListenAndServe(":8080", nil) + if err != nil { + log.Fatal(err) + } } From cb3838981591f347dc525b3b27e730102790fcf7 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Sun, 14 Aug 2022 22:15:28 +0100 Subject: [PATCH 11/11] remove readme in favor of IMPLEMENTATON.md This will lower burden of syncing the README --- http-auth/IMPLEMENTATION.md | 22 +++ http-auth/README.md | 320 ------------------------------------ 2 files changed, 22 insertions(+), 320 deletions(-) create mode 100644 http-auth/IMPLEMENTATION.md delete mode 100644 http-auth/README.md diff --git a/http-auth/IMPLEMENTATION.md b/http-auth/IMPLEMENTATION.md new file mode 100644 index 000000000..f2dca5d28 --- /dev/null +++ b/http-auth/IMPLEMENTATION.md @@ -0,0 +1,22 @@ +# HTTP & Authentication + +In this project you're going toearn about long-lived processes, some simple networking and the basics of HTTP. + +Timebox: 6 days + +Learning objectives: + +- Use Go's net/http package to build start a simple server that responds to local requests +- Get to know HTTP GET and response codes +- Get familiar with cURL +- Define URL, header, body and content-type +- Accept parameters in via GET in the query string +- Accept data via a POST request +- Setup authentication via a basic HTTP auth +- Switch to using JWTs +- Accept multiple forms of authentication +- Write tests for the above + +## Project + +See the `main` branch for instructions for this project. diff --git a/http-auth/README.md b/http-auth/README.md deleted file mode 100644 index 2a4ed7e7d..000000000 --- a/http-auth/README.md +++ /dev/null @@ -1,320 +0,0 @@ -# HTTP & Authentication - -In this project you're going toearn about long-lived processes, some simple networking and the basics of HTTP. - -Timebox: 6 days - -Learning objectives: - -- Use Go's net/http package to build start a simple server that responds to local requests -- Get to know HTTP GET and response codes -- Get familiar with cURL -- Define URL, header, body and content-type -- Accept parameters in via GET in the query string -- Accept data via a POST request -- Setup authentication via a basic HTTP auth -- Switch to using JWTs -- Accept multiple forms of authentication -- Write tests for the above - -## Project - -- `go mod init http-auth` -- create empty main package and main function -- `go run .` -- `import "net/http"` -- Basic server: - -```go -func main() { - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello, world")) - }) - - http.ListenAndServe(":8080", nil) -} -``` - -- User `curl` to interact -- Add handlers such that the following URLs and responses work. Use `http.NotFoundHandler()` - -``` -> curl -i http://localhost:8080/500 -HTTP/1.1 500 Internal Server Error -Date: Sat, 25 Jun 2022 11:16:30 GMT -Content-Length: 21 -Content-Type: text/plain; charset=utf-8 - -Internal server error - -> curl -i http://localhost:8080/200 -HTTP/1.1 200 OK -Date: Sat, 25 Jun 2022 11:17:17 GMT -Content-Length: 3 -Content-Type: text/plain; charset=utf-8 - -200 - -> curl -i http://localhost:8080/404 -HTTP/1.1 404 Not Found -Content-Type: text/plain; charset=utf-8 -X-Content-Type-Options: nosniff -Date: Sat, 25 Jun 2022 11:17:29 GMT -Content-Length: 19 - -404 page not found -``` - -- Make the index page at `/` returns some HTML to a `GET` request - -``` -> curl -i http://localhost:8080/ -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 09:42:30 GMT -Content-Length: 42 - -Hello, world% -``` - -- Make the index page accept `POST` requests with some HTML, and return that HTML: - -``` -> curl -i -d "Hi" http://localhost:8080/ -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 09:43:20 GMT -Content-Length: 32 - -Hi -``` - -- Ensure you've got error handling in the handler function - -- Make the handler at `/` output the query parameters as a list. Having the output spaced over multiple lines is optional, but done here for readability. - -``` -> curl -i http://localhost:8080\?foo=bar -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 09:55:33 GMT -Content-Length: 96 - - - -Hello, world -

    Query parameters: -

      -
    • foo: [bar]
    • -
    -``` - -- Try putting some HTML into the query params or body to see that it is interpreted as HTML: - -``` -> curl -i http://localhost:8080\?foo=\bar\ -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 09:57:20 GMT -Content-Length: 113 - - - -Hello, world -

    Query parameters: -

      -
    • foo: [bar]
    • -
    -``` - -This isn't good! This kind of thing can lead to security issues. Search for "XSS attack" to find out more. Let's fix it. - -- "Escape" the string any time you take some input (data in `POST` or query parameters) and output it back: - -``` -> curl -i http://localhost:8080\?foo=\bar\ -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 10:08:08 GMT -Content-Length: 125 - - - -Hello, world -

    Query parameters: -

      -
    • foo: [<strong>bar</strong>]
    • -
    -``` - -``` -> curl -i -d "Hi" http://localhost:8080/ -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 10:08:21 GMT -Content-Length: 46 - - - -<em>Hi</em> -``` - -- Add an endpoint `/authenticated` that requires the use of HTTP Basic auth. It should return a `401 Unauthorized` status code with a `WWW-Authenticate` header if basic auth is not present or does not match a username and password of your choice. Once Basic Auth is provided, it should respond successful! - -``` -> curl -i http://localhost:8080/authenticated -HTTP/1.1 401 Unauthorized -Www-Authenticate: Basic realm="localhost", charset="UTF-8" -Date: Sun, 24 Jul 2022 14:12:35 GMT -Content-Length: 0 -``` - -``` -> curl -i http://localhost:8080/authenticated -H 'Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=' -HTTP/1.1 200 OK -Content-Type: text/html -Date: Sun, 24 Jul 2022 14:13:04 GMT -Content-Length: 38 - - - -Hello username! -``` - -You can generate the `dXNl...` text [using this website](https://opinionatedgeek.com/Codecs/Base64Encoder). This is "base64 encoded" which you can search for to find a bit more about. Enter `username:password` to get `dXNlcm5hbWU6cGFzc3dvcmQ=`. - -- It's not a good idea to put secrets like passwords into code. So remove any hard-coded usernames and passwords for basic auth, and use `os.Getenv(...)` so that this works: - -``` -> AUTH_USERNAME=admin AUTH_PASSWORD=long-memorable-password go run . -``` - -- [Follow this guide](https://www.datadoghq.com/blog/apachebench/) to install and use ApacheBench, which will test to see how many requests your server can handle - -``` -> ab -n 10000 -c 100 http://localhost:8080/ - -This is ApacheBench, Version 2.3 <$Revision: 1879490 $> -Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ -Licensed to The Apache Software Foundation, http://www.apache.org/ - -Benchmarking localhost (be patient) -Completed 1000 requests -Completed 2000 requests -Completed 3000 requests -Completed 4000 requests -Completed 5000 requests -Completed 6000 requests -Completed 7000 requests -Completed 8000 requests -Completed 9000 requests -Completed 10000 requests -Finished 10000 requests - - -Server Software: -Server Hostname: localhost -Server Port: 8080 - -Document Path: / -Document Length: 76 bytes - -Concurrency Level: 100 -Time taken for tests: 0.779 seconds -Complete requests: 10000 -Failed requests: 0 -Total transferred: 1770000 bytes -HTML transferred: 760000 bytes -Requests per second: 12837.71 [#/sec] (mean) -Time per request: 7.790 [ms] (mean) -Time per request: 0.078 [ms] (mean, across all concurrent requests) -Transfer rate: 2219.02 [Kbytes/sec] received - -Connection Times (ms) - min mean[+/-sd] median max -Connect: 0 4 3.2 3 49 -Processing: 1 4 3.2 4 49 -Waiting: 0 4 3.1 4 49 -Total: 5 8 4.5 7 53 - -Percentage of the requests served within a certain time (ms) - 50% 7 - 66% 8 - 75% 8 - 80% 8 - 90% 8 - 95% 9 - 98% 10 - 99% 11 - 100% 53 (longest request) -``` - -- It's better to protect your server from being asked to handle too many requests than to have it fall over! So use the `rate` library to reject excess requests (> X per second) with a `503 Service Unavailable` error on a `/limited` endpoint. - -``` -> go get -u golang.org/x/time -``` - -You will need to import the module: - -```go -import "golang.org/x/time/rate" -``` - -Then create a limiter: - -```go -lim := rate.NewLimiter(100, 30) -``` - -And use it: - -```go -if lim.Allow() { - // Respond as normal! -} else { - // Respond with an error -} -``` - -If it is working, you will see `Non-2xx responses` and `Failed requests` in your ApacheBench output: - -``` -> ab -n 100 -c 100 http://localhost:8080/limited -... - -Document Path: /limited -Document Length: 35 bytes - -Concurrency Level: 100 -Time taken for tests: 0.006 seconds -Complete requests: 100 -Failed requests: 70 <----- HERE! - (Connect: 0, Receive: 0, Length: 70, Exceptions: 0) -Non-2xx responses: 70 <----- HERE! -Total transferred: 17170 bytes -HTML transferred: 2450 bytes -Requests per second: 15544.85 [#/sec] (mean) -Time per request: 6.433 [ms] (mean) -Time per request: 0.064 [ms] (mean, across all concurrent requests) -Transfer rate: 2606.49 [Kbytes/sec] received - -Connection Times (ms) - min mean[+/-sd] median max -Connect: 0 2 0.7 2 4 -Processing: 1 1 0.3 1 4 -Waiting: 0 1 0.2 1 1 -Total: 2 4 0.7 4 5 - -Percentage of the requests served within a certain time (ms) - 50% 4 - 66% 4 - 75% 4 - 80% 4 - 90% 5 - 95% 5 - 98% 5 - 99% 5 - 100% 5 (longest request) -```