feat: action v2 signing (#8779)

# Which Problems Are Solved

The action v2 messages were didn't contain anything providing security
for the sent content.

# How the Problems Are Solved

Each Target now has a SigningKey, which can also be newly generated
through the API and returned at creation and through the Get-Endpoints.
There is now a HTTP header "Zitadel-Signature", which is generated with
the SigningKey and Payload, and also contains a timestamp to check with
a tolerance if the message took to long to sent.

# Additional Changes

The functionality to create and check the signature is provided in the
pkg/actions package, and can be reused in the SDK.

# Additional Context

Closes #7924

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz
2024-11-28 11:06:52 +01:00
committed by GitHub
parent 8537805ea5
commit 7caa43ab23
37 changed files with 745 additions and 122 deletions

View File

@@ -14,6 +14,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/actions"
)
type ContextInfo interface {
@@ -28,6 +29,7 @@ type Target interface {
GetEndpoint() string
GetTargetType() domain.TargetType
GetTimeout() time.Duration
GetSigningKey() string
}
// CallTargets call a list of targets in order with handling of error and responses
@@ -72,13 +74,13 @@ func CallTarget(
switch target.GetTargetType() {
// get request, ignore response and return request and error for handling in list of targets
case domain.TargetTypeWebhook:
return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody())
return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey())
// get request, return response and error
case domain.TargetTypeCall:
return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody())
return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey())
case domain.TargetTypeAsync:
go func(target Target, info ContextInfoRequest) {
if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()); err != nil {
if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()); err != nil {
logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err)
}
}(target, info)
@@ -89,13 +91,13 @@ func CallTarget(
}
// webhook call a webhook, ignore the response but return the errror
func webhook(ctx context.Context, url string, timeout time.Duration, body []byte) error {
_, err := Call(ctx, url, timeout, body)
func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) error {
_, err := Call(ctx, url, timeout, body, signingKey)
return err
}
// Call function to do a post HTTP request to a desired url with timeout
func Call(ctx context.Context, url string, timeout time.Duration, body []byte) (_ []byte, err error) {
func Call(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) (_ []byte, err error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
ctx, span := tracing.NewSpan(ctx)
defer func() {
@@ -108,6 +110,9 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte) (
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if signingKey != "" {
req.Header.Set(actions.SigningHeader, actions.ComputeSignatureHeader(time.Now(), body, signingKey))
}
client := http.DefaultClient
resp, err := client.Do(req)

View File

@@ -18,6 +18,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/execution"
"github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/actions"
)
func Test_Call(t *testing.T) {
@@ -29,6 +30,7 @@ func Test_Call(t *testing.T) {
body []byte
respBody []byte
statusCode int
signingKey string
}
type res struct {
body []byte
@@ -84,6 +86,22 @@ func Test_Call(t *testing.T) {
body: []byte("{\"response\": \"values\"}"),
},
},
{
"ok, signed",
args{
ctx: context.Background(),
timeout: time.Minute,
sleep: time.Second,
method: http.MethodPost,
body: []byte("{\"request\": \"values\"}"),
respBody: []byte("{\"response\": \"values\"}"),
statusCode: http.StatusOK,
signingKey: "signingkey",
},
res{
body: []byte("{\"response\": \"values\"}"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -95,7 +113,7 @@ func Test_Call(t *testing.T) {
statusCode: tt.args.statusCode,
respondBody: tt.args.respBody,
},
testCall(tt.args.ctx, tt.args.timeout, tt.args.body),
testCall(tt.args.ctx, tt.args.timeout, tt.args.body, tt.args.signingKey),
)
if tt.res.wantErr {
assert.Error(t, err)
@@ -186,6 +204,29 @@ func Test_CallTarget(t *testing.T) {
body: nil,
},
},
{
"webhook, signed, ok",
args{
ctx: context.Background(),
info: requestContextInfo1,
server: &callTestServer{
timeout: time.Second,
method: http.MethodPost,
expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"),
respondBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusOK,
signingKey: "signingkey",
},
target: &mockTarget{
TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute,
SigningKey: "signingkey",
},
},
res{
body: nil,
},
},
{
"request response, error",
args{
@@ -228,6 +269,29 @@ func Test_CallTarget(t *testing.T) {
body: []byte("{\"request\":\"content2\"}"),
},
},
{
"request response, signed, ok",
args{
ctx: context.Background(),
info: requestContextInfo1,
server: &callTestServer{
timeout: time.Second,
method: http.MethodPost,
expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"),
respondBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusOK,
signingKey: "signingkey",
},
target: &mockTarget{
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
SigningKey: "signingkey",
},
},
res{
body: []byte("{\"request\":\"content2\"}"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -392,6 +456,7 @@ type mockTarget struct {
Endpoint string
Timeout time.Duration
InterruptOnError bool
SigningKey string
}
func (e *mockTarget) GetTargetID() string {
@@ -409,6 +474,9 @@ func (e *mockTarget) GetTargetType() domain.TargetType {
func (e *mockTarget) GetTimeout() time.Duration {
return e.Timeout
}
func (e *mockTarget) GetSigningKey() string {
return e.SigningKey
}
type callTestServer struct {
method string
@@ -416,6 +484,7 @@ type callTestServer struct {
timeout time.Duration
statusCode int
respondBody []byte
signingKey string
}
func testServers(
@@ -447,7 +516,7 @@ func listen(
c *callTestServer,
) (url string, close func()) {
handler := func(w http.ResponseWriter, r *http.Request) {
checkRequest(t, r, c.method, c.expectBody)
checkRequest(t, r, c.method, c.expectBody, c.signingKey)
if c.statusCode != http.StatusOK {
http.Error(w, "error", c.statusCode)
@@ -466,16 +535,19 @@ func listen(
return server.URL, server.Close
}
func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte) {
func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte, signingKey string) {
sentBody, err := io.ReadAll(sent.Body)
require.NoError(t, err)
require.Equal(t, expectedBody, sentBody)
require.Equal(t, method, sent.Method)
if signingKey != "" {
require.NoError(t, actions.ValidatePayload(sentBody, sent.Header.Get(actions.SigningHeader), signingKey))
}
}
func testCall(ctx context.Context, timeout time.Duration, body []byte) func(string) ([]byte, error) {
func testCall(ctx context.Context, timeout time.Duration, body []byte, signingKey string) func(string) ([]byte, error) {
return func(url string) ([]byte, error) {
return execution.Call(ctx, url, timeout, body)
return execution.Call(ctx, url, timeout, body, signingKey)
}
}