Files
zitadel/internal/api/grpc/server/middleware/execution_interceptor_test.go
8e82ec1cb9 Merge commit from fork
* Add DenyLists parsing

* Remove unneeded returned error

* Plug global denylist into Command

* app creation: apply denylist to backchannel logout URI

* Inject denylist to backchannel logout worker

* webhook config: validate against blocked URLs

* Add notificationsWebhook denylist target

* command: Add SMTP endpoint validation against blocklist

* command: Add SMS endpoint validation against blocklist

* Validate webhook endpoint against denylist on channel notification

* Remove unused tests

* handle deprecated denylists

* remove unintended denylist entry in deprecated list

* use single http client

* fix tests

* update comments

* fixes

* cleanup

* address comments

* fix merge

---------

Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
2026-06-15 15:36:14 +02:00

945 lines
26 KiB
Go

package middleware
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/denylist"
"github.com/zitadel/zitadel/internal/execution"
target_domain "github.com/zitadel/zitadel/internal/execution/target"
)
var (
privateKey = func() *rsa.PrivateKey {
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
return privateKey
}()
encryptionKey = func() []byte {
data, _ := crypto.PublicKeyToBytes(&privateKey.PublicKey)
return data
}()
encryptionKeyID = "encryption-key-id"
signingAlgorithm = jose.RS256
)
func newMockContentRequest(content string) proto.Message {
return &structpb.Struct{
Fields: map[string]*structpb.Value{
"content": {
Kind: &structpb.Value_StringValue{StringValue: content},
},
},
}
}
func newMockContextInfoRequest(fullMethod, request string) *ContextInfoRequest {
return &ContextInfoRequest{
FullMethod: fullMethod,
Request: Message{Message: newMockContentRequest(request)},
}
}
func newMockContextInfoResponse(fullMethod, request, response string) *ContextInfoResponse {
return &ContextInfoResponse{
FullMethod: fullMethod,
Request: Message{Message: newMockContentRequest(request)},
Response: Message{Message: newMockContentRequest(response)},
}
}
func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")}
type target struct {
reqBody execution.ContextInfo
sleep time.Duration
statusCode int
respBody interface{}
requestVerification func(*testing.T) func([]byte, []byte) bool
}
type args struct {
ctx context.Context
executionTargets []target_domain.Target
targets []target
fullMethod string
req interface{}
getActiveSigningWebKey execution.GetActiveSigningWebKey
deniedIPs []denylist.AddressChecker
}
type res struct {
want interface{}
wantErr bool
}
tests := []struct {
name string
args args
res res
}{
{
"target, executionTargets nil",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: nil,
req: newMockContentRequest("request"),
},
res{
want: newMockContentRequest("request"),
},
},
{
"target, executionTargets empty",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{},
req: newMockContentRequest("request"),
},
res{
want: newMockContentRequest("request"),
},
},
{
"target, not reachable",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"target, error without interrupt",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusBadRequest,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
want: newMockContentRequest("content"),
},
},
{
"target, interruptOnError",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusBadRequest,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"target, timeout",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Second,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 5 * time.Second,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"target, wrong request",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Second,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "wrong"),
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"target, ok",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
want: newMockContentRequest("content1"),
},
},
{
"when target endpoint is in denylist should return error",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
deniedIPs: deniedIPs,
},
res{
wantErr: true,
},
},
{
"target async, timeout",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeAsync,
Timeout: time.Second,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 5 * time.Second,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
want: newMockContentRequest("content"),
},
},
{
"target async, ok",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeAsync,
Timeout: time.Minute,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
want: newMockContentRequest("content"),
},
},
{
"webhook, error",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeWebhook,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
sleep: 0,
statusCode: http.StatusInternalServerError,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"webhook, timeout",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeWebhook,
Timeout: time.Second,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 5 * time.Second,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"webhook, ok",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeWebhook,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
want: newMockContentRequest("content"),
},
},
{
"with includes, interruptOnError",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target1",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target2",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target3",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
{
reqBody: newMockContextInfoRequest("/service/method", "content1"),
respBody: newMockContentRequest("content2"),
sleep: 0,
statusCode: http.StatusBadRequest,
requestVerification: validateJSONPayload,
},
{
reqBody: newMockContextInfoRequest("/service/method", "content2"),
respBody: newMockContentRequest("content3"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"with includes, timeout",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target1",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target2",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Second,
InterruptOnError: true,
},
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target3",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Second,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
{
reqBody: newMockContextInfoRequest("/service/method", "content1"),
respBody: newMockContentRequest("content2"),
sleep: 5 * time.Second,
statusCode: http.StatusBadRequest,
requestVerification: validateJSONPayload,
},
{
reqBody: newMockContextInfoRequest("/service/method", "content2"),
respBody: newMockContentRequest("content3"),
sleep: 5 * time.Second,
statusCode: http.StatusOK,
requestVerification: validateJSONPayload,
},
},
req: newMockContentRequest("content"),
},
res{
wantErr: true,
},
},
{
"payload JWT, ok",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeWebhook,
Timeout: time.Minute,
InterruptOnError: true,
PayloadType: target_domain.PayloadTypeJWT,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJWTPayload,
},
},
req: newMockContentRequest("content"),
getActiveSigningWebKey: mockGetActiveSigningWebKey(),
},
res{
want: newMockContentRequest("content"),
},
},
{
"payload JWE, ok",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeWebhook,
Timeout: time.Minute,
InterruptOnError: true,
PayloadType: target_domain.PayloadTypeJWE,
EncryptionKey: encryptionKey,
EncryptionKeyID: encryptionKeyID,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest("content1"),
sleep: 0,
statusCode: http.StatusOK,
requestVerification: validateJWEPayload,
},
},
req: newMockContentRequest("content"),
getActiveSigningWebKey: mockGetActiveSigningWebKey(),
},
res{
want: newMockContentRequest("content"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
closeFuncs := make([]func(), len(tt.args.targets))
for i, target := range tt.args.targets {
url, closeF := testServerCall(
target.reqBody,
target.sleep,
target.statusCode,
target.respBody,
target.requestVerification(t),
)
tt.args.executionTargets[i].Endpoint = url
closeFuncs[i] = closeF
}
resp, err := executeTargetsForRequest(
tt.args.ctx,
tt.args.executionTargets,
tt.args.fullMethod,
tt.args.req,
nil,
tt.args.getActiveSigningWebKey,
&http.Client{Transport: denylist.NewHTTPTransport(tt.args.deniedIPs)},
)
if tt.res.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.EqualExportedValues(t, tt.res.want, resp)
for _, closeF := range closeFuncs {
closeF()
}
})
}
}
func testServerCall(
reqBody interface{},
sleep time.Duration,
statusCode int,
respBody interface{},
requestVerification func(expected, sent []byte) bool,
) (string, func()) {
handler := func(w http.ResponseWriter, r *http.Request) {
data, err := json.Marshal(reqBody)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
sentBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if !requestVerification(data, sentBody) {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if statusCode != http.StatusOK {
http.Error(w, "error", statusCode)
return
}
time.Sleep(sleep)
w.Header().Set("Content-Type", "application/json")
resp, err := protojson.Marshal(respBody.(proto.Message))
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if _, err := w.Write(resp); err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
}
server := httptest.NewServer(http.HandlerFunc(handler))
return server.URL, server.Close
}
func mockGetActiveSigningWebKey() func(ctx context.Context) (*jose.JSONWebKey, error) {
return func(ctx context.Context) (*jose.JSONWebKey, error) {
return &jose.JSONWebKey{
Key: privateKey,
Algorithm: string(signingAlgorithm),
Use: "sig",
}, nil
}
}
func validateJSONPayload(t *testing.T) func(expected, sent []byte) bool {
return bytes.Equal
}
func validateJWTPayload(t *testing.T) func(expected, sent []byte) bool {
return func(expected, sent []byte) bool {
jws, err := jose.ParseSigned(string(sent), []jose.SignatureAlgorithm{jose.RS256})
require.NoError(t, err)
payload, err := jws.Verify(privateKey.Public())
require.NoError(t, err)
return bytes.Equal(expected, payload)
}
}
func validateJWEPayload(t *testing.T) func(expected, sent []byte) bool {
return func(expected, sent []byte) bool {
parsedJWE, err := jose.ParseEncrypted(string(sent), []jose.KeyAlgorithm{jose.RSA_OAEP_256, jose.ECDH_ES_A256KW}, []jose.ContentEncryption{jose.A256GCM})
if err != nil {
return false
}
require.Equal(t, encryptionKeyID, parsedJWE.Header.KeyID)
require.Equal(t, "JWT", parsedJWE.Header.ExtraHeaders[jose.HeaderContentType].(string))
decryptedJWS, err := parsedJWE.Decrypt(privateKey)
require.NoError(t, err)
return validateJWTPayload(t)(expected, decryptedJWS)
}
}
func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) {
deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")}
type target struct {
reqBody execution.ContextInfo
sleep time.Duration
statusCode int
respBody interface{}
}
type args struct {
ctx context.Context
executionTargets []target_domain.Target
targets []target
fullMethod string
req interface{}
resp interface{}
deniedIPs []denylist.AddressChecker
}
type res struct {
want interface{}
wantErr bool
}
tests := []struct {
name string
args args
res res
}{
{
"target, executionTargets nil",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: nil,
req: newMockContentRequest("request"),
resp: newMockContentRequest("response"),
},
res{
want: newMockContentRequest("response"),
},
},
{
"target, executionTargets empty",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{},
req: newMockContentRequest("request"),
resp: newMockContentRequest("response"),
},
res{
want: newMockContentRequest("response"),
},
},
{
"target, empty response",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoRequest("/service/method", "content"),
respBody: newMockContentRequest(""),
sleep: 0,
statusCode: http.StatusOK,
},
},
req: newMockContentRequest(""),
resp: newMockContentRequest(""),
},
res{
wantErr: true,
},
},
{
"target, ok",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "response./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoResponse("/service/method", "request", "response"),
respBody: newMockContentRequest("response1"),
sleep: 0,
statusCode: http.StatusOK,
},
},
req: newMockContentRequest("request"),
resp: newMockContentRequest("response"),
},
res{
want: newMockContentRequest("response1"),
},
},
{
"when target endpoint is in deny list should return error",
args{
ctx: context.Background(),
fullMethod: "/service/method",
executionTargets: []target_domain.Target{
{
ExecutionID: "response./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: target_domain.TargetTypeCall,
Timeout: time.Minute,
InterruptOnError: true,
},
},
targets: []target{
{
reqBody: newMockContextInfoResponse("/service/method", "request", "response"),
respBody: newMockContentRequest("response1"),
sleep: 0,
statusCode: http.StatusOK,
},
},
req: newMockContentRequest("request"),
resp: newMockContentRequest("response"),
deniedIPs: deniedIPs,
},
res{
wantErr: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
closeFuncs := make([]func(), len(tt.args.targets))
for i, target := range tt.args.targets {
url, closeF := testServerCall(
target.reqBody,
target.sleep,
target.statusCode,
target.respBody,
validateJSONPayload(t),
)
tt.args.executionTargets[i].Endpoint = url
closeFuncs[i] = closeF
}
resp, err := executeTargetsForResponse(
tt.args.ctx,
tt.args.executionTargets,
tt.args.fullMethod,
tt.args.req,
tt.args.resp,
nil,
mockGetActiveSigningWebKey(),
&http.Client{Transport: denylist.NewHTTPTransport(tt.args.deniedIPs)},
)
if tt.res.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.EqualExportedValues(t, tt.res.want, resp)
for _, closeF := range closeFuncs {
closeF()
}
})
}
}