Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds JSON Validation Middleware, fixes #1163 #1180

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions api_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const (
MethodTransformed
RequestTracked
RequestNotTracked
ValidateJSONRequest
)

// RequestStatus is a custom type to avoid collisions
Expand Down Expand Up @@ -76,6 +77,7 @@ const (
StatusRequestSizeControlled RequestStatus = "Request Size Limited"
StatusRequesTracked RequestStatus = "Request Tracked"
StatusRequestNotTracked RequestStatus = "Request Not Tracked"
StatusValidateJSON RequestStatus = "Validate JSON"
)

// URLSpec represents a flattened specification for URLs, used to check if a proxy URL
Expand All @@ -97,6 +99,7 @@ type URLSpec struct {
MethodTransform apidef.MethodTransformMeta
TrackEndpoint apidef.TrackEndpointMeta
DoNotTrackEndpoint apidef.TrackEndpointMeta
ValidatePathMeta apidef.ValidatePathMeta
}

type TransformSpec struct {
Expand Down Expand Up @@ -691,6 +694,20 @@ func (a APIDefinitionLoader) compileTrackedEndpointPathspathSpec(paths []apidef.
return urlSpec
}

func (a APIDefinitionLoader) compileValidateJSONPathspathSpec(paths []apidef.ValidatePathMeta, stat URLStatus) []URLSpec {
urlSpec := []URLSpec{}

for _, stringSpec := range paths {
newSpec := URLSpec{}
a.generateRegex(stringSpec.Path, &newSpec, stat)
// Extend with method actions
newSpec.ValidatePathMeta = stringSpec
urlSpec = append(urlSpec, newSpec)
}

return urlSpec
}

func (a APIDefinitionLoader) compileUnTrackedEndpointPathspathSpec(paths []apidef.TrackEndpointMeta, stat URLStatus) []URLSpec {
urlSpec := []URLSpec{}

Expand Down Expand Up @@ -724,6 +741,7 @@ func (a APIDefinitionLoader) getExtendedPathSpecs(apiVersionDef apidef.VersionIn
methodTransforms := a.compileMethodTransformSpec(apiVersionDef.ExtendedPaths.MethodTransforms, MethodTransformed)
trackedPaths := a.compileTrackedEndpointPathspathSpec(apiVersionDef.ExtendedPaths.TrackEndpoints, RequestTracked)
unTrackedPaths := a.compileUnTrackedEndpointPathspathSpec(apiVersionDef.ExtendedPaths.DoNotTrackEndpoints, RequestNotTracked)
validateJSON := a.compileValidateJSONPathspathSpec(apiVersionDef.ExtendedPaths.ValidateJSON, ValidateJSONRequest)

combinedPath := []URLSpec{}
combinedPath = append(combinedPath, ignoredPaths...)
Expand All @@ -742,6 +760,7 @@ func (a APIDefinitionLoader) getExtendedPathSpecs(apiVersionDef apidef.VersionIn
combinedPath = append(combinedPath, methodTransforms...)
combinedPath = append(combinedPath, trackedPaths...)
combinedPath = append(combinedPath, unTrackedPaths...)
combinedPath = append(combinedPath, validateJSON...)

return combinedPath, len(whiteListPaths) > 0
}
Expand Down Expand Up @@ -787,6 +806,9 @@ func (a *APISpec) getURLStatus(stat URLStatus) RequestStatus {
return StatusRequesTracked
case RequestNotTracked:
return StatusRequestNotTracked
case ValidateJSONRequest:
return StatusValidateJSON

default:
log.Error("URL Status was not one of Ignored, Blacklist or WhiteList! Blocking.")
return EndPointNotAllowed
Expand Down Expand Up @@ -911,6 +933,10 @@ func (a *APISpec) CheckSpecMatchesStatus(r *http.Request, rxPaths []URLSpec, mod
if r.Method == v.DoNotTrackEndpoint.Method {
return true, &v.DoNotTrackEndpoint
}
case ValidateJSONRequest:
if r.Method == v.ValidatePathMeta.Method {
return true, &v.ValidatePathMeta
}
}
}
return false, nil
Expand Down
2 changes: 2 additions & 0 deletions api_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ func processSpec(spec *APISpec, apisByListen map[string]int,
mwAppendEnabled(&chainArray, &IPWhiteListMiddleware{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &OrganizationMonitor{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &RateLimitForAPI{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &ValidateJSON{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &MiddlewareContextVars{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &VersionCheck{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &RequestSizeLimitMiddleware{baseMid})
Expand Down Expand Up @@ -448,6 +449,7 @@ func processSpec(spec *APISpec, apisByListen map[string]int,
mwAppendEnabled(&chainArray, &RateLimitForAPI{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &RateLimitAndQuotaCheck{baseMid})
mwAppendEnabled(&chainArray, &GranularAccessMiddleware{baseMid})
mwAppendEnabled(&chainArray, &ValidateJSON{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &TransformMiddleware{baseMid})
mwAppendEnabled(&chainArray, &TransformHeaders{BaseMiddleware: baseMid})
mwAppendEnabled(&chainArray, &URLRewriteMiddleware{BaseMiddleware: baseMid})
Expand Down
7 changes: 7 additions & 0 deletions apidef/api_definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ type MethodTransformMeta struct {
ToMethod string `bson:"to_method" json:"to_method"`
}

type ValidatePathMeta struct {
Path string `bson:"path" json:"path"`
Method string `bson:"method" json:"method"`
ValidateWith string `bson:"validate_with" json:"validate_with"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not allow this to be a file path too, like we do with others?

If you're only going to allow a base64 string, you can use []byte as a type and encoding/json already uses base64 implicitly.

}

type ExtendedPathsSet struct {
Ignored []EndPointMeta `bson:"ignored" json:"ignored,omitempty"`
WhiteList []EndPointMeta `bson:"white_list" json:"white_list,omitempty"`
Expand All @@ -152,6 +158,7 @@ type ExtendedPathsSet struct {
MethodTransforms []MethodTransformMeta `bson:"method_transforms" json:"method_transforms,omitempty"`
TrackEndpoints []TrackEndpointMeta `bson:"track_endpoints" json:"track_endpoints,omitempty"`
DoNotTrackEndpoints []TrackEndpointMeta `bson:"do_not_track_endpoints" json:"do_not_track_endpoints,omitempty"`
ValidateJSON []ValidatePathMeta `bson:"validate_json" json:"validate_json,omitempty"`
}

type VersionInfo struct {
Expand Down
94 changes: 94 additions & 0 deletions mw_validate_json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"net/http"

"github.com/xeipuuv/gojsonschema"

"github.com/TykTechnologies/tyk/apidef"
)

var serverError error = errors.New("validation failed, server error")

type ValidateJSON struct {
BaseMiddleware
}

func (k *ValidateJSON) Name() string {
return "ValidateJSON"
}

func (k *ValidateJSON) EnabledForSpec() bool {
for _, v := range k.Spec.VersionData.Versions {
if len(v.ExtendedPaths.ValidateJSON) > 0 {
return true
}
}
return false
}

// ProcessRequest will run any checks on the request on the way through the system, return an error to have the chain fail
func (k *ValidateJSON) ProcessRequest(w http.ResponseWriter, r *http.Request, _ interface{}) (error, int) {

_, versionPaths, _, _ := k.Spec.Version(r)
found, meta := k.Spec.CheckSpecMatchesStatus(r, versionPaths, ValidateJSONRequest)
if !found {
return nil, 200
}
mmeta := meta.(*apidef.ValidatePathMeta)

if mmeta.ValidateWith == "" {
return serverError, 400

}

rCopy := copyRequest(r)
body, err := ioutil.ReadAll(rCopy.Body)
if err != nil {
log.Error("")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

return serverError, 400
}

return validateJSONSchema(mmeta.ValidateWith, string(body))
}

func getJSONSchemaLoader(rawString string) (gojsonschema.JSONLoader, error) {
sDec, err := base64.StdEncoding.DecodeString(rawString)
if err != nil {
return nil, err
}

ldr := gojsonschema.NewStringLoader(string(sDec))
return ldr, nil
}

func validateJSONSchema(validateWith string, body string) (error, int) {
sch, err := getJSONSchemaLoader(validateWith)

if err != nil {
log.Error("Can't continue with request validation, failed to retrieve schema: ", err)
return serverError, 400
}

ldr := gojsonschema.NewStringLoader(string(body))

result, err := gojsonschema.Validate(sch, ldr)
if err != nil {
log.Error("Can't continue with request validation, process failed: ", err)
return serverError, 400
}

if !result.Valid() {
errStr := "payload validation failed"
for _, desc := range result.Errors() {
errStr = fmt.Sprintf("%s: %s", errStr, desc)
}
return errors.New(errStr), 400
}

return nil, 200
}
170 changes: 170 additions & 0 deletions mw_validate_json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package main

import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"

"github.com/justinas/alice"
)

var schema string = `{
"title": "Person",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
}
},
"required": ["firstName", "lastName"]
}`

const validateJSONPathGatewaySetup = `{
"api_id": "jsontest",
"definition": {
"location": "header",
"key": "version"
},
"auth": {"auth_header_name": "authorization"},
"version_data": {
"not_versioned": true,
"versions": {
"default": {
"name": "default",
"use_extended_paths": true,
"extended_paths": {
"validate_json": [{
"method": "POST",
"path": "me",
"validate_with": "ew0KICAgICJ0aXRsZSI6ICJQZXJzb24iLA0KICAgICJ0eXBlIjogIm9iamVjdCIsDQogICAgInByb3BlcnRpZXMiOiB7DQogICAgICAgICJmaXJzdE5hbWUiOiB7DQogICAgICAgICAgICAidHlwZSI6ICJzdHJpbmciDQogICAgICAgIH0sDQogICAgICAgICJsYXN0TmFtZSI6IHsNCiAgICAgICAgICAgICJ0eXBlIjogInN0cmluZyINCiAgICAgICAgfSwNCiAgICAgICAgImFnZSI6IHsNCiAgICAgICAgICAgICJkZXNjcmlwdGlvbiI6ICJBZ2UgaW4geWVhcnMiLA0KICAgICAgICAgICAgInR5cGUiOiAiaW50ZWdlciIsDQogICAgICAgICAgICAibWluaW11bSI6IDANCiAgICAgICAgfQ0KICAgIH0sDQogICAgInJlcXVpcmVkIjogWyJmaXJzdE5hbWUiLCAibGFzdE5hbWUiXQ0KfQ=="
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please don't say that this is a base64-encoded schema :) this should be in plaintext, and encoded as part of the test to simplify the code and help readability/maintainability.

}]
}
}
}
},
"proxy": {
"listen_path": "/validate/",
"target_url": "` + testHttpAny + `"
}
}`

type out struct {
Error string
Code int
}

func TestValidateSchema(t *testing.T) {
want := []out{
{"validation failed, server error", 400},
{"payload validation failed: firstName: firstName is required: lastName: lastName is required", 400},
{"payload validation failed: lastName: lastName is required", 400},
{"", 200},
}

set := []string{
``,
`{}`,
`{"firstName":"foo"}`,
`{"firstName":"foo", "lastName":"foo"}`,
}

sch := base64.StdEncoding.EncodeToString([]byte(schema))
for i, in := range set {
e, code := validateJSONSchema(sch, in)
if want[i].Error == "" {
if e == nil && code != want[i].Code {
t.Fatalf("Wanted nil error / %v, got %v / %v", want[i].Code, e, code)
}
} else {
if e.Error() != want[i].Error || code != want[i].Code {
t.Fatalf("Wanted: %v / %v, got %v / %v", want[i].Error, want[i].Code, e, code)
}
}

}
}

func createJSONVersionedSession() *SessionState {
session := new(SessionState)
session.Rate = 10000
session.Allowance = session.Rate
session.LastCheck = time.Now().Unix()
session.Per = 60
session.Expires = -1
session.QuotaRenewalRate = 300 // 5 minutes
session.QuotaRenews = time.Now().Unix()
session.QuotaRemaining = 10
session.QuotaMax = -1
session.AccessRights = map[string]AccessDefinition{"jsontest": {APIName: "Tyk Test API", APIID: "jsontest", Versions: []string{"default"}}}
return session
}

func getJSONValidChain(spec *APISpec) http.Handler {
remote, _ := url.Parse(spec.Proxy.TargetURL)
proxy := TykNewSingleHostReverseProxy(remote, spec)
proxyHandler := ProxyHandler(proxy, spec)
baseMid := BaseMiddleware{spec, proxy}
chain := alice.New(mwList(
&IPWhiteListMiddleware{baseMid},
&MiddlewareContextVars{BaseMiddleware: baseMid},
&AuthKey{baseMid},
&VersionCheck{BaseMiddleware: baseMid},
&KeyExpired{baseMid},
&AccessRightsCheck{baseMid},
&RateLimitAndQuotaCheck{baseMid},
&ValidateJSON{BaseMiddleware: baseMid},
&TransformHeaders{baseMid},
)...).Then(proxyHandler)
return chain
}

func TestValidateSchemaMW(t *testing.T) {
spec := createSpecTest(t, validateJSONPathGatewaySetup)
recorder := httptest.NewRecorder()
req := testReq(t, "POST", "/validate/me", `{"firstName":"foo", "lastName":"bar"}`)

session := createJSONVersionedSession()
spec.SessionManager.UpdateSession("986968696869688869696999", session, 60)
req.Header.Set("Authorization", "986968696869688869696999")

chain := getJSONValidChain(spec)
chain.ServeHTTP(recorder, req)

if recorder.Code != 200 {
t.Fatalf("Initial request failed with non-200 to: %v, code: %v (body: %v)", req.URL.String(), recorder.Code, recorder.Body)
}
}

func TestValidateSchemaMWInvalid(t *testing.T) {
spec := createSpecTest(t, validateJSONPathGatewaySetup)
recorder := httptest.NewRecorder()
req := testReq(t, "POST", "/validate/me", `{"firstName":"foo"}`)

session := createJSONVersionedSession()
spec.SessionManager.UpdateSession("986968696869688869696999", session, 60)
req.Header.Set("Authorization", "986968696869688869696999")

chain := getJSONValidChain(spec)
chain.ServeHTTP(recorder, req)

if recorder.Code != 400 {
t.Fatalf("Request code should have been 400: %v, code: %v (body: %v)", req.URL.String(), recorder.Code, recorder.Body)
}

want := "payload validation failed: lastName: lastName is required"
if !strings.Contains(recorder.Body.String(), want) {
t.Fatalf("Body shoul dhave contained error: %v, got: %v", want, recorder.Body.String())
}
}
Loading