Merge branch '18-firewall-task' into 'master'
Resolve "firewall task" Closes #18 See merge request slon/shad-go-private!24
This commit is contained in:
commit
e63fdb8c75
5 changed files with 398 additions and 0 deletions
56
firewall/README.md
Normal file
56
firewall/README.md
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
## firewall
|
||||||
|
|
||||||
|
В этой задаче нужно написать примитивный файрвол.
|
||||||
|
|
||||||
|
Файрвол - это прокси сервер, пропускающий через себя все запросы,
|
||||||
|
и отвергающий некоторые из них по заданному набору правил.
|
||||||
|
|
||||||
|
Пример правил можно посмотреть в [example.yaml](./configs/example.yaml).
|
||||||
|
Все правила можно разделить на 2 группы: те, что приминяются к запросу и те, что приминяются к ответу.
|
||||||
|
|
||||||
|
Для решения этой задачи удобно использовать `http.Transport`.
|
||||||
|
|
||||||
|
На все заблокированные запросы нужно отвечать статусом 403 и строкой `Forbidden`.
|
||||||
|
|
||||||
|
Сервер должен принимать следующие агрументы:
|
||||||
|
* `-service-addr` - адрес защищаемого сервиса
|
||||||
|
* `-conf` - путь к .yaml конфигу с правилами
|
||||||
|
* `-addr` - адрес, на котором будет развёрнут файрвол
|
||||||
|
|
||||||
|
## Примеры:
|
||||||
|
В [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
|
||||||
|
* reverse proxy: https://en.wikipedia.org/wiki/Reverse_proxy
|
||||||
|
* yaml: https://gopkg.in/yaml.v2
|
||||||
|
* regexp: https://golang.org/pkg/regexp/
|
7
firewall/cmd/firewall/main.go
Normal file
7
firewall/cmd/firewall/main.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// +build !solution
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
}
|
282
firewall/cmd/firewall/main_test.go
Normal file
282
firewall/cmd/firewall/main_test.go
Normal file
|
@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
24
firewall/cmd/service/main.go
Normal file
24
firewall/cmd/service/main.go
Normal file
|
@ -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))
|
||||||
|
}
|
29
firewall/configs/example.yaml
Normal file
29
firewall/configs/example.yaml
Normal file
|
@ -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.*'
|
Loading…
Reference in a new issue