-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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("") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
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==" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) | ||
} | ||
} |
There was a problem hiding this comment.
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 andencoding/json
already uses base64 implicitly.