Skip to content

Commit

Permalink
Adds JSON Schema validation to the gateway
Browse files Browse the repository at this point in the history
Fixes #1163
Based on #1180

Adds a new JSON Validation middleware that can be configured as follows:

```
"version_data": {
  "not_versioned": true,
  "versions": {
    "default": {
      "name": "default",
      "use_extended_paths": true,
      "extended_paths": {
        "validate_json": [{
          "method": "POST",
          "path": "me",
          "validate_with": "BASE64 ENCODED SCHEMA"
        }]
      }
    }
  }
},
```

The schema must be a draft v4 JSON Schema spec. The gateway will attempt
to validate the inbound request against it, if fields are failing the
validation process, a detailed error response is provided for the user
to fix their payload.

This will require a new Dashboard UI to handle input.
  • Loading branch information
lonelycode authored and asoorm committed Dec 11, 2017
1 parent 37a2953 commit aca8584
Show file tree
Hide file tree
Showing 7 changed files with 311 additions and 8 deletions.
26 changes: 26 additions & 0 deletions api_definition.go
Expand Up @@ -51,6 +51,7 @@ const (
MethodTransformed
RequestTracked
RequestNotTracked
ValidateJSONRequest
)

// RequestStatus is a custom type to avoid collisions
Expand Down Expand Up @@ -79,6 +80,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 @@ -100,6 +102,7 @@ type URLSpec struct {
MethodTransform apidef.MethodTransformMeta
TrackEndpoint apidef.TrackEndpointMeta
DoNotTrackEndpoint apidef.TrackEndpointMeta
ValidatePathMeta apidef.ValidatePathMeta
}

type TransformSpec struct {
Expand Down Expand Up @@ -699,6 +702,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 @@ -732,6 +749,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 @@ -750,6 +768,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 @@ -795,6 +814,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 @@ -928,6 +950,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
Expand Up @@ -304,6 +304,7 @@ func processSpec(spec *APISpec, apisByListen map[string]int,
mwAppendEnabled(&chainArray, &CertificateCheckMW{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 @@ -441,6 +442,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
8 changes: 8 additions & 0 deletions apidef/api_definitions.go
Expand Up @@ -135,6 +135,13 @@ 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"`
ValidateWith64 string `bson:"validate_with_64" json:"validate_with_64,omitempty"`
ValidateWithFile string `bson:"validate_with_file" json:"validate_with_file,omitempty"`
}

type ExtendedPathsSet struct {
Ignored []EndPointMeta `bson:"ignored" json:"ignored,omitempty"`
WhiteList []EndPointMeta `bson:"white_list" json:"white_list,omitempty"`
Expand All @@ -152,6 +159,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
81 changes: 81 additions & 0 deletions mw_validate_json.go
@@ -0,0 +1,81 @@
package main

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

"github.com/xeipuuv/gojsonschema"

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

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.ValidateWith64 == "" {
return errors.New("no schemas to validate against"), 400
}

rCopy := copyRequest(r)
bodyBytes, err := ioutil.ReadAll(rCopy.Body)
if err != nil {
return err, 400
}
defer rCopy.Body.Close()

schemaBytes, err := base64.StdEncoding.DecodeString(mmeta.ValidateWith64)
if err != nil {
return errors.New("unable to base64 decode schema"), 400
}

result, err := k.validate(bodyBytes, schemaBytes)
if err != nil {
return err, 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
}

func (k *ValidateJSON) validate(input []byte, rules []byte) (*gojsonschema.Result, error) {
inputLoader := gojsonschema.NewBytesLoader(input)
rulesLoader := gojsonschema.NewBytesLoader(rules)

return gojsonschema.Validate(rulesLoader, inputLoader)
}
191 changes: 191 additions & 0 deletions mw_validate_json_test.go
@@ -0,0 +1,191 @@
package main

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

"github.com/TykTechnologies/tyk/user"
"github.com/justinas/alice"
)

var schema = `{
"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_64": "ew0KICAgICJ0aXRsZSI6ICJQZXJzb24iLA0KICAgICJ0eXBlIjogIm9iamVjdCIsDQogICAgInByb3BlcnRpZXMiOiB7DQogICAgICAgICJmaXJzdE5hbWUiOiB7DQogICAgICAgICAgICAidHlwZSI6ICJzdHJpbmciDQogICAgICAgIH0sDQogICAgICAgICJsYXN0TmFtZSI6IHsNCiAgICAgICAgICAgICJ0eXBlIjogInN0cmluZyINCiAgICAgICAgfSwNCiAgICAgICAgImFnZSI6IHsNCiAgICAgICAgICAgICJkZXNjcmlwdGlvbiI6ICJBZ2UgaW4geWVhcnMiLA0KICAgICAgICAgICAgInR5cGUiOiAiaW50ZWdlciIsDQogICAgICAgICAgICAibWluaW11bSI6IDANCiAgICAgICAgfQ0KICAgIH0sDQogICAgInJlcXVpcmVkIjogWyJmaXJzdE5hbWUiLCAibGFzdE5hbWUiXQ0KfQ=="
}]
}
}
}
},
"proxy": {
"listen_path": "/validate/",
"target_url": "` + testHttpAny + `"
}
}`

type res struct {
Error string
Code int
}

type fixture struct {
in string
out res
name string
}

var fixtures = []fixture{
{
in: `{"age":23, "firstName": "Harry"}`,
out: res{Error: `lastName: lastName is required`, Code: 400},
name: "missing field",
},
{
in: `{"age":23}`,
out: res{Error: `firstName: firstName is required: lastName: lastName is required`, Code: 400},
name: "missing two fields",
},
{
in: `{}`,
out: res{Error: `firstName: firstName is required: lastName: lastName is required`, Code: 400},
name: "empty object",
},
{
in: `[]`,
out: res{Error: `(root): Invalid type. Expected: object, given: array`, Code: 400},
name: "array",
},
{
in: `{"age":"23", "firstName": "Harry", "lastName": "Potter"}`,
out: res{Error: `age: Invalid type. Expected: integer, given: string`, Code: 400},
name: "wrong type",
},
{
in: `{"age":23, "firstName": "Harry", "lastName": "Potter"}`,
out: res{Error: `null`, Code: 200},
name: "valid",
},
}

func TestValidateJSON_validate(t *testing.T) {

for _, f := range fixtures {

t.Run(f.name, func(st *testing.T) {
vj := ValidateJSON{}

res, _ := vj.validate([]byte(f.in), []byte(schema))

st.Log("in:", f.in)
if !res.Valid() && f.out.Code != 400 {
st.Error("Expected invalid")
}

if res.Valid() && f.out.Code != 200 {
st.Error("expected valid")
}
})
}
}

func TestValidateJSON_ProcessRequest(t *testing.T) {

for _, f := range fixtures {

t.Run(f.name, func(st *testing.T) {

spec := createSpecTest(st, validateJSONPathGatewaySetup)
recorder := httptest.NewRecorder()
req := testReq(t, "POST", "/validate/me", f.in)

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

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

if recorder.Code != f.out.Code {
st.Errorf("failed: %v, code: %v (body: %v)", req.URL.String(), recorder.Code, recorder.Body)
}

if f.out.Code == 400 {
recorderBody := recorder.Body.String()
if !strings.Contains(recorderBody, f.out.Error) {
st.Errorf("Incorrect error msg:\nwant: %v\ngot: %v", f.out.Error, recorderBody)
}
}
})
}
}

func createJSONVersionedSession() *user.SessionState {
session := new(user.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]user.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
}

0 comments on commit aca8584

Please sign in to comment.