Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds JSON Schema validation to the gateway
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
1 parent
37a2953
commit aca8584
Showing
7 changed files
with
311 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Oops, something went wrong.