From 89141afd3bf5d82f7b0283376bac0be64dce29bc Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Mon, 21 Mar 2022 10:33:11 +0100 Subject: [PATCH] Add intercept response filter --- .../projects/graphql/activeProject.graphql | 4 +- .../features/reqlog/components/Actions.tsx | 2 +- .../features/settings/components/Settings.tsx | 141 ++++++++++++--- .../graphql/updateInterceptSettings.graphql | 4 +- admin/src/lib/graphql/generated.tsx | 20 ++- pkg/api/generated.go | 146 ++++++++++++++-- pkg/api/models_gen.go | 12 +- pkg/api/resolvers.go | 35 +++- pkg/api/schema.graphql | 8 +- pkg/proj/proj.go | 22 ++- pkg/proxy/intercept/filter.go | 165 ++++++++++++++++++ pkg/proxy/intercept/intercept.go | 73 ++++---- pkg/proxy/intercept/intercept_test.go | 15 +- pkg/proxy/intercept/settings.go | 6 +- pkg/reqlog/reqlog.go | 16 +- 15 files changed, 556 insertions(+), 113 deletions(-) diff --git a/admin/src/features/projects/graphql/activeProject.graphql b/admin/src/features/projects/graphql/activeProject.graphql index 4327af0..4aa3711 100644 --- a/admin/src/features/projects/graphql/activeProject.graphql +++ b/admin/src/features/projects/graphql/activeProject.graphql @@ -5,8 +5,10 @@ query ActiveProject { isActive settings { intercept { - enabled + requestsEnabled + responsesEnabled requestFilter + responseFilter } } } diff --git a/admin/src/features/reqlog/components/Actions.tsx b/admin/src/features/reqlog/components/Actions.tsx index bc16d7b..e95e1f7 100644 --- a/admin/src/features/reqlog/components/Actions.tsx +++ b/admin/src/features/reqlog/components/Actions.tsx @@ -29,7 +29,7 @@ function Actions(): JSX.Element { {clearLogsResult.error && Failed to clear HTTP logs: {clearLogsResult.error}} - {activeProject?.settings.intercept.enabled && ( + {(activeProject?.settings.intercept.requestsEnabled || activeProject?.settings.intercept.responsesEnabled) && ( + + Responses + + + + } + label="Enable response interception" + labelPlacement="start" + sx={{ display: "inline-block", m: 0 }} + /> + + When enabled, HTTP responses received by the proxy are stalled for{" "} + manual review. + + +
+ + setInterceptResFilter(e.target.value)} + /> + + Filter expression to match received responses on. When set, only matching responses are intercepted. + + + +
diff --git a/admin/src/features/settings/graphql/updateInterceptSettings.graphql b/admin/src/features/settings/graphql/updateInterceptSettings.graphql index 0bcf99f..ea9c516 100644 --- a/admin/src/features/settings/graphql/updateInterceptSettings.graphql +++ b/admin/src/features/settings/graphql/updateInterceptSettings.graphql @@ -1,6 +1,8 @@ mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { updateInterceptSettings(input: $input) { - enabled + requestsEnabled + responsesEnabled requestFilter + responseFilter } } diff --git a/admin/src/lib/graphql/generated.tsx b/admin/src/lib/graphql/generated.tsx index 6cd3ed7..c64dcbf 100644 --- a/admin/src/lib/graphql/generated.tsx +++ b/admin/src/lib/graphql/generated.tsx @@ -135,8 +135,10 @@ export type HttpResponseLog = { export type InterceptSettings = { __typename?: 'InterceptSettings'; - enabled: Scalars['Boolean']; requestFilter?: Maybe; + requestsEnabled: Scalars['Boolean']; + responseFilter?: Maybe; + responsesEnabled: Scalars['Boolean']; }; export type ModifyRequestInput = { @@ -359,8 +361,10 @@ export type SenderRequestInput = { }; export type UpdateInterceptSettingsInput = { - enabled: Scalars['Boolean']; requestFilter?: InputMaybe; + requestsEnabled: Scalars['Boolean']; + responseFilter?: InputMaybe; + responsesEnabled: Scalars['Boolean']; }; export type CancelRequestMutationVariables = Exact<{ @@ -401,7 +405,7 @@ export type ModifyResponseMutation = { __typename?: 'Mutation', modifyResponse: export type ActiveProjectQueryVariables = Exact<{ [key: string]: never; }>; -export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', enabled: boolean, requestFilter?: string | null } } } | null }; +export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } } } | null }; export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>; @@ -513,7 +517,7 @@ export type UpdateInterceptSettingsMutationVariables = Exact<{ }>; -export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', enabled: boolean, requestFilter?: string | null } }; +export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } }; export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>; @@ -715,8 +719,10 @@ export const ActiveProjectDocument = gql` isActive settings { intercept { - enabled + requestsEnabled + responsesEnabled requestFilter + responseFilter } } } @@ -1381,8 +1387,10 @@ export type GetSenderRequestsQueryResult = Apollo.QueryResult rightVal, nil + case search.TokOpLt: + // TODO(?) attempt to parse as int. + return leftVal < rightVal, nil + case search.TokOpGtEq: + // TODO(?) attempt to parse as int. + return leftVal >= rightVal, nil + case search.TokOpLtEq: + // TODO(?) attempt to parse as int. + return leftVal <= rightVal, nil + default: + return false, errors.New("unsupported operator") + } +} + +func getMappedStringLiteralFromRes(res *http.Response, s string) (string, error) { + fn, ok := resFilterKeyFns[s] + if ok { + return fn(res) + } + + return s, nil +} + +func matchResStringLiteral(res *http.Response, strLiteral search.StringLiteral) (bool, error) { + for _, fn := range resFilterKeyFns { + value, err := fn(res) + if err != nil { + return false, err + } + + if strings.Contains(strings.ToLower(value), strings.ToLower(strLiteral.Value)) { + return true, nil + } + } + + return false, nil +} diff --git a/pkg/proxy/intercept/intercept.go b/pkg/proxy/intercept/intercept.go index 06c8441..caf2f99 100644 --- a/pkg/proxy/intercept/intercept.go +++ b/pkg/proxy/intercept/intercept.go @@ -53,14 +53,19 @@ type Service struct { requests map[ulid.ULID]Request responses map[ulid.ULID]Response logger log.Logger - enabled bool - reqFilter search.Expression + + requestsEnabled bool + responsesEnabled bool + reqFilter search.Expression + resFilter search.Expression } type Config struct { - Logger log.Logger - Enabled bool - RequestFilter search.Expression + Logger log.Logger + RequestsEnabled bool + ResponsesEnabled bool + RequestFilter search.Expression + ResponseFilter search.Expression } // RequestIDs implements sort.Interface. @@ -68,13 +73,15 @@ type RequestIDs []ulid.ULID func NewService(cfg Config) *Service { s := &Service{ - reqMu: &sync.RWMutex{}, - resMu: &sync.RWMutex{}, - requests: make(map[ulid.ULID]Request), - responses: make(map[ulid.ULID]Response), - logger: cfg.Logger, - enabled: cfg.Enabled, - reqFilter: cfg.RequestFilter, + reqMu: &sync.RWMutex{}, + resMu: &sync.RWMutex{}, + requests: make(map[ulid.ULID]Request), + responses: make(map[ulid.ULID]Response), + logger: cfg.Logger, + requestsEnabled: cfg.RequestsEnabled, + responsesEnabled: cfg.ResponsesEnabled, + reqFilter: cfg.RequestFilter, + resFilter: cfg.ResponseFilter, } if s.logger == nil { @@ -122,7 +129,7 @@ func (svc *Service) InterceptRequest(ctx context.Context, req *http.Request) (*h return req, nil } - if !svc.enabled { + if !svc.requestsEnabled { // If request intercept is disabled, return the incoming request as-is. svc.logger.Debugw("Bypassed request interception: feature disabled.") return req, nil @@ -267,14 +274,20 @@ func (svc *Service) Items() []Item { } func (svc *Service) UpdateSettings(settings Settings) { - // When updating from `enabled` -> `disabled`, clear any pending reqs. - if svc.enabled && !settings.Enabled { + // When updating from requests `enabled` -> `disabled`, clear any pending reqs. + if svc.requestsEnabled && !settings.RequestsEnabled { svc.ClearRequests() + } + + // When updating from responses `enabled` -> `disabled`, clear any pending responses. + if svc.responsesEnabled && !settings.ResponsesEnabled { svc.ClearResponses() } - svc.enabled = settings.Enabled + svc.requestsEnabled = settings.RequestsEnabled + svc.responsesEnabled = settings.ResponsesEnabled svc.reqFilter = settings.RequestFilter + svc.resFilter = settings.ResponseFilter } // ItemByID returns an intercepted item (request and possible response) by ID. It's safe for concurrent use. @@ -358,25 +371,25 @@ func (svc *Service) InterceptResponse(ctx context.Context, res *http.Response) ( return res, nil } - if !svc.enabled { - // If the feature is disabled, return the response as-is. + // If global response intercept is disabled and interception is *not* explicitly enabled for this response: bypass. + if !svc.responsesEnabled && !(ok && shouldIntercept) { svc.logger.Debugw("Bypassed response interception: feature disabled.") return res, nil } - // if svc.reqFilter != nil { - // match, err := MatchRequestFilter(req, svc.reqFilter) - // if err != nil { - // return nil, fmt.Errorf("intercept: failed to match request rules for request (id: %v): %w", - // reqID.String(), err, - // ) - // } + if svc.resFilter != nil { + match, err := MatchResponseFilter(res, svc.resFilter) + if err != nil { + return nil, fmt.Errorf("intercept: failed to match response rules for response (id: %v): %w", + reqID.String(), err, + ) + } - // if !match { - // svc.logger.Debugw("Bypassed interception: request rules don't match.") - // return req, nil - // } - // } + if !match { + svc.logger.Debugw("Bypassed response interception: response rules don't match.") + return res, nil + } + } ch := make(chan *http.Response) done := make(chan struct{}) diff --git a/pkg/proxy/intercept/intercept_test.go b/pkg/proxy/intercept/intercept_test.go index 2ed20f8..bb5e91d 100644 --- a/pkg/proxy/intercept/intercept_test.go +++ b/pkg/proxy/intercept/intercept_test.go @@ -28,8 +28,9 @@ func TestRequestModifier(t *testing.T) { logger, _ := zap.NewDevelopment() svc := intercept.NewService(intercept.Config{ - Logger: logger.Sugar(), - Enabled: true, + Logger: logger.Sugar(), + RequestsEnabled: true, + ResponsesEnabled: false, }) reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy) @@ -45,8 +46,9 @@ func TestRequestModifier(t *testing.T) { logger, _ := zap.NewDevelopment() svc := intercept.NewService(intercept.Config{ - Logger: logger.Sugar(), - Enabled: true, + Logger: logger.Sugar(), + RequestsEnabled: true, + ResponsesEnabled: false, }) ctx, cancel := context.WithCancel(context.Background()) @@ -88,8 +90,9 @@ func TestRequestModifier(t *testing.T) { logger, _ := zap.NewDevelopment() svc := intercept.NewService(intercept.Config{ - Logger: logger.Sugar(), - Enabled: true, + Logger: logger.Sugar(), + RequestsEnabled: true, + ResponsesEnabled: false, }) var got *http.Request diff --git a/pkg/proxy/intercept/settings.go b/pkg/proxy/intercept/settings.go index 084b35d..15bc154 100644 --- a/pkg/proxy/intercept/settings.go +++ b/pkg/proxy/intercept/settings.go @@ -3,6 +3,8 @@ package intercept import "github.com/dstotijn/hetty/pkg/search" type Settings struct { - Enabled bool - RequestFilter search.Expression + RequestsEnabled bool + ResponsesEnabled bool + RequestFilter search.Expression + ResponseFilter search.Expression } diff --git a/pkg/reqlog/reqlog.go b/pkg/reqlog/reqlog.go index d7e652e..65b7f2e 100644 --- a/pkg/reqlog/reqlog.go +++ b/pkg/reqlog/reqlog.go @@ -217,14 +217,16 @@ func (svc *service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon clone := *res - // TODO: Use io.LimitReader. - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("reqlog: could not read response body: %w", err) - } + if res.Body != nil { + // TODO: Use io.LimitReader. + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("reqlog: could not read response body: %w", err) + } - res.Body = ioutil.NopCloser(bytes.NewBuffer(body)) - clone.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + res.Body = io.NopCloser(bytes.NewBuffer(body)) + clone.Body = io.NopCloser(bytes.NewBuffer(body)) + } go func() { if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil {