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:
verytable 2020-04-02 20:11:37 +00:00
commit e63fdb8c75
5 changed files with 398 additions and 0 deletions

56
firewall/README.md Normal file
View 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/

View file

@ -0,0 +1,7 @@
// +build !solution
package main
func main() {
}

View 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())
})
}
}

View 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))
}

View 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.*'