From d127113918b84aadf1b6360743b70d4ce9e8313d Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Thu, 2 Apr 2020 22:25:32 +0300 Subject: [PATCH] Add firewall task. --- firewall/README.md | 47 +++++ firewall/cmd/firewall/main.go | 7 + firewall/cmd/firewall/main_test.go | 282 +++++++++++++++++++++++++++++ firewall/cmd/service/main.go | 24 +++ firewall/configs/example.yaml | 29 +++ 5 files changed, 389 insertions(+) create mode 100644 firewall/README.md create mode 100644 firewall/cmd/firewall/main.go create mode 100644 firewall/cmd/firewall/main_test.go create mode 100644 firewall/cmd/service/main.go create mode 100644 firewall/configs/example.yaml diff --git a/firewall/README.md b/firewall/README.md new file mode 100644 index 0000000..089e055 --- /dev/null +++ b/firewall/README.md @@ -0,0 +1,47 @@ +## firewall + +В этой задаче нужно написать примитивный файрвол. + +Файрвол - это прокси сервер, пропускающий через себя все запросы, +и отвергающий некоторые из них по заданному набору правил. + +Пример правил можно посмотреть в [example.yaml](./configs/example.yaml). +Все правила можно разделить на 2 группы: те, что приминяются к запросу и те, что приминяются к ответу. + +На все заблокированные запросы нужно отвечать статусом 403 и строкой `Forbidden`. + +## Примеры: +В [http service](./cmd/service/main.go) находится примитивный сервис, который мы хотим защитить. +``` +go run ./firewall/cmd/service/main.go -port 8080 +``` +``` +curl -i http://localhost:8080/list -d '"loooooooooooooooooooooooooooooong-line"' +HTTP/1.1 200 OK +Date: Thu, 02 Apr 2020 19:14:36 GMT +Content-Length: 40 +Content-Type: text/plain; charset=utf-8 + +"loooooooooooooooooooooooooooooong-line" +``` +Стартуем firewall: +``` +go run ./firewall/cmd/firewall/main.go -service-addr http://localhost:8080 -addr localhost:8081 -conf ./firewall/configs/example.yaml +``` +Делем тот же запрос через него: +``` +curl -i http://localhost:8081/list -d '"loooooooooooooooooooooooooooooong-line"' +HTTP/1.1 403 Forbidden +Date: Thu, 02 Apr 2020 19:14:40 GMT +Content-Length: 9 +Content-Type: text/plain; charset=utf-8 + +Forbidden +``` +Сработало правило на максимальную длину запроса. + + +## Resources + +* project layout: https://github.com/golang-standards/project-layout +* yaml: https://gopkg.in/yaml.v2 diff --git a/firewall/cmd/firewall/main.go b/firewall/cmd/firewall/main.go new file mode 100644 index 0000000..cab7d3a --- /dev/null +++ b/firewall/cmd/firewall/main.go @@ -0,0 +1,7 @@ +// +build !solution + +package main + +func main() { + +} diff --git a/firewall/cmd/firewall/main_test.go b/firewall/cmd/firewall/main_test.go new file mode 100644 index 0000000..a87fdd4 --- /dev/null +++ b/firewall/cmd/firewall/main_test.go @@ -0,0 +1,282 @@ +package main + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path" + "testing" + "time" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" + + "gitlab.com/slon/shad-go/tools/testtool" +) + +const importPath = "gitlab.com/slon/shad-go/firewall/cmd/firewall" + +var binCache testtool.BinCache + +func TestMain(m *testing.M) { + os.Exit(func() int { + var teardown testtool.CloseFunc + binCache, teardown = testtool.NewBinCache() + defer teardown() + + return m.Run() + }()) +} + +func storeConfig(t *testing.T, conf string) (filename string, cleanup func()) { + t.Helper() + + filename = path.Join(os.TempDir(), testtool.RandomName()+".yaml") + err := ioutil.WriteFile(filename, []byte(conf), 0777) + require.NoError(t, err) + + cleanup = func() { _ = os.Remove(filename) } + return +} + +func startServer(t *testing.T, serviceURL string, conf string) (port string, stop func()) { + binary, err := binCache.GetBinary(importPath) + require.NoError(t, err) + + confPath, removeConf := storeConfig(t, conf) + defer removeConf() + + port, err = testtool.GetFreePort() + require.NoError(t, err, "unable to get free port") + + addr := fmt.Sprintf("localhost:%s", port) + + cmd := exec.Command(binary, "-service-addr", serviceURL, "-addr", addr, "-conf", confPath) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + + require.NoError(t, cmd.Start()) + + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + stop = func() { + _ = cmd.Process.Kill() + <-done + } + + if err = testtool.WaitForPort(t, time.Second*5, port); err != nil { + stop() + } + + require.NoError(t, err) + return +} + +func TestFirewall(t *testing.T) { + echoService := func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(w, r.Body) + defer func() { _ = r.Body.Close() }() + w.Header().Set("Content-Length", fmt.Sprintf("%d", r.ContentLength)) + } + + c := resty.New() + + type result struct { + code int + body string + } + + for _, tc := range []struct { + name string + conf string + service http.HandlerFunc + makeRequest func() *resty.Request + expected result + }{ + { + name: "empty", + conf: ``, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello") + }, + expected: result{code: http.StatusOK, body: "hello"}, + }, + { + name: "simple", + conf: ` +rules: + - endpoint: "/" + forbidden_user_agents: + - 'python-requests.*' + forbidden_headers: + - 'Content-Type: text/html' + required_headers: + - "Content-Type" + max_request_length_bytes: 50 + max_response_length_bytes: 50 + forbidden_response_codes: [201] + forbidden_request_re: + - '.*(\.\./){3,}.*' + forbidden_response_re: + - '.*admin.*' +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R(). + SetBody(`{"user_id": 123, "path": "../../user"}`). + SetHeaders(map[string]string{ + "User-Agent": "Mozilla/5.0", + "Content-Type": "application/json", + }) + }, + expected: result{code: http.StatusOK, body: `{"user_id": 123, "path": "../../user"}`}, + }, + { + name: "bad-user-agent", + conf: ` +rules: + - endpoint: "/" + forbidden_user_agents: + - 'python-requests.*' +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello").SetHeader("User-Agent", "python-requests/2.22.0") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "forbidden-header", + conf: ` +rules: + - endpoint: "/" + forbidden_headers: + - 'Content-Type: text/html' +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello").SetHeader("Content-Type", "text/html") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "missing-required-header", + conf: ` +rules: + - endpoint: "/" + required_headers: + - "Content-Type" + - "Content-Length" +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello").SetHeader("Content-Length", "5") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "max-request-length-exceeded", + conf: ` +rules: + - endpoint: "/" + max_request_length_bytes: 4 +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "max-response-length-exceeded", + conf: ` +rules: + - endpoint: "/" + max_response_length_bytes: 4 +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "bad-status-code", + conf: ` +rules: + - endpoint: "/" + forbidden_response_codes: [20] +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "bad-status-code", + conf: ` +rules: + - endpoint: "/" + forbidden_response_codes: [20] +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody("hello") + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "bad-response", + conf: ` +rules: + - endpoint: "/" + forbidden_request_re: + - '.*(\.\./){3,}.*' +`, + service: func(w http.ResponseWriter, r *http.Request) {}, + makeRequest: func() *resty.Request { + return c.R().SetBody(`{"path": "../../../../etc.passwd"}`) + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + { + name: "bad-response", + conf: ` +rules: + - endpoint: "/" + forbidden_response_re: + - '.*admin.*' +`, + service: echoService, + makeRequest: func() *resty.Request { + return c.R().SetBody(`{"user": "admin", "password": "1234"}`) + }, + expected: result{code: http.StatusForbidden, body: "Forbidden"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + service := httptest.NewServer(tc.service) + defer service.Close() + + port, cleanup := startServer(t, service.URL, tc.conf) + defer cleanup() + + u := fmt.Sprintf("http://localhost:%s", port) + + resp, err := tc.makeRequest().Post(u) + require.NoError(t, err) + + require.Equal(t, tc.expected.code, resp.StatusCode()) + require.Equal(t, tc.expected.body, resp.String()) + }) + } +} diff --git a/firewall/cmd/service/main.go b/firewall/cmd/service/main.go new file mode 100644 index 0000000..f88c2e6 --- /dev/null +++ b/firewall/cmd/service/main.go @@ -0,0 +1,24 @@ +// +build !change + +package main + +import ( + "flag" + "fmt" + "io" + "log" + "net/http" +) + +func main() { + port := flag.String("port", "", "port to listen") + flag.Parse() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(w, r.Body) + defer func() { _ = r.Body.Close() }() + w.Header().Set("Content-Length", fmt.Sprintf("%d", r.ContentLength)) + }) + + log.Fatal(http.ListenAndServe(":"+*port, nil)) +} diff --git a/firewall/configs/example.yaml b/firewall/configs/example.yaml new file mode 100644 index 0000000..403fbdb --- /dev/null +++ b/firewall/configs/example.yaml @@ -0,0 +1,29 @@ +rules: + - endpoint: "/list" + + # Regular expressions that forbid specific user agents. + forbidden_user_agents: + - 'python-requests.*' + + # Regular expressions that forbid specific header values. + forbidden_headers: + - 'Content-Type: text/html' + + required_headers: + - "Content-Type" + - "Content-Length" + + max_request_length_bytes: 20 + max_response_length_bytes: 50 + + forbidden_response_codes: [201] + + - endpoint: "/login" + + # Regular expressions that ban specific requests. + forbidden_request_re: + - '.*(\.\./){3,}.*' + + # Regular expressions that ban specific responses. + forbidden_response_re: + - '.*admin.*'