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