mirror of
https://github.com/zitadel/zitadel.git
synced 2025-07-15 00:38:35 +00:00

<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> # Which Problems Are Solved A customer reached out that after an upgrade, actions would always fail with the error "host is denied" when calling an external API. This is due to a security fix (https://github.com/zitadel/zitadel/security/advisories/GHSA-6cf5-w9h3-4rqv), where a DNS lookup was added to check whether the host name resolves to a denied IP or subnet. If the lookup fails due to the internal DNS setup, the action fails as well. Additionally, the lookup was also performed when the deny list was empty. # How the Problems Are Solved - Prevent DNS lookup when deny list is empty - Properly initiate deny list and prevent empty entries # Additional Changes - Log the reason for blocked address (domain, IP, subnet) # Additional Context - reported by a customer - needs backport to 2.70.x, 2.71.x and 3.0.0 rc
481 lines
10 KiB
Go
481 lines
10 KiB
Go
package actions
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/zitadel/zitadel/internal/logstore"
|
|
"github.com/zitadel/zitadel/internal/logstore/record"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
func Test_isHostBlocked(t *testing.T) {
|
|
SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
|
|
var denyList = []AddressChecker{
|
|
mustNewHostChecker(t, "192.168.5.0/24"),
|
|
mustNewHostChecker(t, "127.0.0.1"),
|
|
mustNewHostChecker(t, "test.com"),
|
|
}
|
|
type fields struct {
|
|
lookup func(host string) ([]net.IP, error)
|
|
}
|
|
type args struct {
|
|
address *url.URL
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
want error
|
|
}{
|
|
{
|
|
name: "in range",
|
|
args: args{
|
|
address: mustNewURL(t, "https://192.168.5.4/hodor"),
|
|
},
|
|
want: NewAddressDeniedError("192.168.5.0/24"),
|
|
},
|
|
{
|
|
name: "exact ip",
|
|
args: args{
|
|
address: mustNewURL(t, "http://127.0.0.1:8080/hodor"),
|
|
},
|
|
want: NewAddressDeniedError("127.0.0.1"),
|
|
},
|
|
{
|
|
name: "address match",
|
|
fields: fields{
|
|
lookup: func(host string) ([]net.IP, error) {
|
|
return []net.IP{net.ParseIP("194.264.52.4")}, nil
|
|
},
|
|
},
|
|
args: args{
|
|
address: mustNewURL(t, "https://test.com:42/hodor"),
|
|
},
|
|
want: NewAddressDeniedError("test.com"),
|
|
},
|
|
{
|
|
name: "address not match",
|
|
fields: fields{
|
|
lookup: func(host string) ([]net.IP, error) {
|
|
return []net.IP{net.ParseIP("194.264.52.4")}, nil
|
|
},
|
|
},
|
|
args: args{
|
|
address: mustNewURL(t, "https://test2.com/hodor"),
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "looked up ip matches",
|
|
fields: fields{
|
|
lookup: func(host string) ([]net.IP, error) {
|
|
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
|
},
|
|
},
|
|
args: args{
|
|
address: mustNewURL(t, "https://test2.com/hodor"),
|
|
},
|
|
want: NewAddressDeniedError("127.0.0.1"),
|
|
},
|
|
{
|
|
name: "looked up failure",
|
|
fields: fields{
|
|
lookup: func(host string) ([]net.IP, error) {
|
|
return nil, errors.New("some error")
|
|
},
|
|
},
|
|
args: args{
|
|
address: mustNewURL(t, "https://test2.com/hodor"),
|
|
},
|
|
want: zerrors.ThrowInternal(nil, "ACTIO-4m9s2", "lookup failed"),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
trans := &transport{
|
|
lookup: tt.fields.lookup,
|
|
}
|
|
got := trans.isHostBlocked(denyList, tt.args.address)
|
|
assert.ErrorIs(t, got, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustNewHostChecker(t *testing.T, ip string) AddressChecker {
|
|
t.Helper()
|
|
checker, err := NewHostChecker(ip)
|
|
if err != nil {
|
|
t.Errorf("unable to parse cidr of %q because: %v", ip, err)
|
|
t.FailNow()
|
|
}
|
|
return checker
|
|
}
|
|
|
|
func mustNewURL(t *testing.T, raw string) *url.URL {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Errorf("unable to parse address of %q because: %v", raw, err)
|
|
t.FailNow()
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestHTTP_fetchConfigFromArg(t *testing.T) {
|
|
runtime := goja.New()
|
|
runtime.SetFieldNameMapper(goja.UncapFieldNameMapper())
|
|
|
|
type args struct {
|
|
arg *goja.Object
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantConfig fetchConfig
|
|
wantErr func(error) bool
|
|
}{
|
|
{
|
|
name: "no fetch option provided",
|
|
args: args{
|
|
arg: runtime.ToValue(
|
|
struct{}{},
|
|
).ToObject(runtime),
|
|
},
|
|
wantConfig: fetchConfig{},
|
|
wantErr: func(err error) bool {
|
|
return err == nil
|
|
},
|
|
},
|
|
{
|
|
name: "header set as string",
|
|
args: args{
|
|
arg: runtime.ToValue(
|
|
&struct {
|
|
Headers map[string]string
|
|
}{
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer token",
|
|
},
|
|
},
|
|
).ToObject(runtime),
|
|
},
|
|
wantConfig: fetchConfig{
|
|
Headers: http.Header{
|
|
"Authorization": {"Bearer token"},
|
|
},
|
|
},
|
|
wantErr: func(err error) bool {
|
|
return err == nil
|
|
},
|
|
},
|
|
{
|
|
name: "header set as list",
|
|
args: args{
|
|
arg: runtime.ToValue(
|
|
&struct {
|
|
Headers map[string][]any
|
|
}{
|
|
Headers: map[string][]any{
|
|
"Authorization": {"Bearer token"},
|
|
},
|
|
},
|
|
).ToObject(runtime),
|
|
},
|
|
wantConfig: fetchConfig{
|
|
Headers: http.Header{
|
|
"Authorization": {"Bearer token"},
|
|
},
|
|
},
|
|
wantErr: func(err error) bool {
|
|
return err == nil
|
|
},
|
|
},
|
|
{
|
|
name: "method set",
|
|
args: args{
|
|
arg: runtime.ToValue(
|
|
&struct {
|
|
Method string
|
|
}{
|
|
Method: http.MethodPost,
|
|
},
|
|
).ToObject(runtime),
|
|
},
|
|
wantConfig: fetchConfig{
|
|
Method: http.MethodPost,
|
|
},
|
|
wantErr: func(err error) bool {
|
|
return err == nil
|
|
},
|
|
},
|
|
{
|
|
name: "body set",
|
|
args: args{
|
|
arg: runtime.ToValue(
|
|
&struct {
|
|
Body struct{ Id string }
|
|
}{
|
|
Body: struct{ Id string }{
|
|
Id: "asdf123",
|
|
},
|
|
},
|
|
).ToObject(runtime),
|
|
},
|
|
wantConfig: fetchConfig{
|
|
Body: bytes.NewReader([]byte(`{"id":"asdf123"}`)),
|
|
},
|
|
wantErr: func(err error) bool {
|
|
return err == nil
|
|
},
|
|
},
|
|
{
|
|
name: "invalid header",
|
|
args: args{
|
|
arg: runtime.ToValue(
|
|
&struct {
|
|
NotExists struct{}
|
|
}{
|
|
NotExists: struct{}{},
|
|
},
|
|
).ToObject(runtime),
|
|
},
|
|
wantConfig: fetchConfig{},
|
|
wantErr: func(err error) bool {
|
|
return zerrors.IsErrorInvalidArgument(err)
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &HTTP{
|
|
runtime: runtime,
|
|
client: http.DefaultClient,
|
|
}
|
|
gotConfig := new(fetchConfig)
|
|
|
|
err := c.fetchConfigFromArg(tt.args.arg, gotConfig)
|
|
if !tt.wantErr(err) {
|
|
t.Errorf("HTTP.fetchConfigFromArg() error = %v", err)
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(gotConfig.Headers, tt.wantConfig.Headers) {
|
|
t.Errorf("config.Headers got = %#v, want %#v", gotConfig.Headers, tt.wantConfig.Headers)
|
|
}
|
|
if gotConfig.Method != tt.wantConfig.Method {
|
|
t.Errorf("config.Method got = %#v, want %#v", gotConfig.Method, tt.wantConfig.Method)
|
|
}
|
|
|
|
if tt.wantConfig.Body == nil {
|
|
if gotConfig.Body != nil {
|
|
t.Errorf("didn't expect a body")
|
|
}
|
|
return
|
|
}
|
|
|
|
gotBody, _ := io.ReadAll(gotConfig.Body)
|
|
wantBody, _ := io.ReadAll(tt.wantConfig.Body)
|
|
|
|
if !reflect.DeepEqual(gotBody, wantBody) {
|
|
t.Errorf("config.Body got = %s, want %s", gotBody, wantBody)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTP_buildHTTPRequest(t *testing.T) {
|
|
runtime := goja.New()
|
|
runtime.SetFieldNameMapper(goja.UncapFieldNameMapper())
|
|
|
|
type args struct {
|
|
args []goja.Value
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantReq *http.Request
|
|
shouldPanic bool
|
|
}{
|
|
{
|
|
name: "only url",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
},
|
|
},
|
|
wantReq: &http.Request{
|
|
Method: http.MethodGet,
|
|
URL: mustNewURL(t, "http://my-url.ch"),
|
|
Header: defaultFetchConfig.Headers,
|
|
Body: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "no params",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue(&struct{}{}),
|
|
},
|
|
},
|
|
wantReq: &http.Request{
|
|
Method: http.MethodGet,
|
|
URL: mustNewURL(t, "http://my-url.ch"),
|
|
Header: defaultFetchConfig.Headers,
|
|
Body: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "overwrite headers",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue(struct {
|
|
Headers map[string][]interface{}
|
|
}{
|
|
Headers: map[string][]interface{}{"Authorization": {"some token"}},
|
|
}),
|
|
},
|
|
},
|
|
wantReq: &http.Request{
|
|
Method: http.MethodGet,
|
|
URL: mustNewURL(t, "http://my-url.ch"),
|
|
Header: http.Header{
|
|
"Authorization": []string{"some token"},
|
|
},
|
|
Body: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "post with body",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue(struct {
|
|
Body struct{ MyData string }
|
|
}{
|
|
Body: struct{ MyData string }{MyData: "hello world"},
|
|
}),
|
|
},
|
|
},
|
|
wantReq: &http.Request{
|
|
Method: http.MethodGet,
|
|
URL: mustNewURL(t, "http://my-url.ch"),
|
|
Header: defaultFetchConfig.Headers,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(`{"myData":"hello world"}`))),
|
|
},
|
|
},
|
|
{
|
|
name: "too many args",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue("http://my-url.ch"),
|
|
},
|
|
},
|
|
wantReq: nil,
|
|
shouldPanic: true,
|
|
},
|
|
{
|
|
name: "no args",
|
|
args: args{
|
|
args: []goja.Value{},
|
|
},
|
|
wantReq: nil,
|
|
shouldPanic: true,
|
|
},
|
|
{
|
|
name: "invalid config",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue(struct {
|
|
Invalid bool
|
|
}{
|
|
Invalid: true,
|
|
}),
|
|
},
|
|
},
|
|
wantReq: nil,
|
|
shouldPanic: true,
|
|
},
|
|
{
|
|
name: "invalid method",
|
|
args: args{
|
|
args: []goja.Value{
|
|
runtime.ToValue("http://my-url.ch"),
|
|
runtime.ToValue(struct {
|
|
Method string
|
|
}{
|
|
Method: " asdf asdf",
|
|
}),
|
|
},
|
|
},
|
|
wantReq: nil,
|
|
shouldPanic: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
panicked := false
|
|
if tt.shouldPanic {
|
|
defer func() {
|
|
if panicked != tt.shouldPanic {
|
|
t.Errorf("wanted panic: %v got %v", tt.shouldPanic, panicked)
|
|
}
|
|
}()
|
|
defer func() {
|
|
recover()
|
|
panicked = true
|
|
}()
|
|
}
|
|
|
|
c := &HTTP{
|
|
runtime: runtime,
|
|
}
|
|
|
|
gotReq := c.buildHTTPRequest(context.Background(), tt.args.args)
|
|
|
|
if tt.shouldPanic {
|
|
return
|
|
}
|
|
|
|
if gotReq.URL.String() != tt.wantReq.URL.String() {
|
|
t.Errorf("url = %s, want %s", gotReq.URL, tt.wantReq.URL)
|
|
}
|
|
|
|
if !reflect.DeepEqual(gotReq.Header, tt.wantReq.Header) {
|
|
t.Errorf("headers = %v, want %v", gotReq.Header, tt.wantReq.Header)
|
|
}
|
|
|
|
if gotReq.Method != tt.wantReq.Method {
|
|
t.Errorf("method = %s, want %s", gotReq.Method, tt.wantReq.Method)
|
|
}
|
|
|
|
if tt.wantReq.Body == nil {
|
|
if gotReq.Body != nil {
|
|
t.Errorf("didn't expect a body")
|
|
}
|
|
return
|
|
}
|
|
|
|
gotBody, _ := io.ReadAll(gotReq.Body)
|
|
wantBody, _ := io.ReadAll(tt.wantReq.Body)
|
|
|
|
if !reflect.DeepEqual(gotBody, wantBody) {
|
|
t.Errorf("config.Body got = %s, want %s", gotBody, wantBody)
|
|
}
|
|
})
|
|
}
|
|
}
|