Skip to content

Commit 909012a

Browse files
ySnoopyDogydaveshanley
authored andcommitted
implement x-www-form-urlencoded validation
Potentially closes #180
1 parent 89ccb53 commit 909012a

16 files changed

Lines changed: 1306 additions & 45 deletions

config/config.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,18 @@ type RegexCache interface {
2121
//
2222
// Generally fluent With... style functions are used to establish the desired behavior.
2323
type ValidationOptions struct {
24-
RegexEngine jsonschema.RegexpEngine
25-
RegexCache RegexCache // Enable compiled regex caching
26-
FormatAssertions bool
27-
ContentAssertions bool
28-
SecurityValidation bool
29-
OpenAPIMode bool // Enable OpenAPI-specific vocabulary validation
30-
AllowScalarCoercion bool // Enable string->boolean/number coercion
31-
Formats map[string]func(v any) error
32-
SchemaCache cache.SchemaCache // Optional cache for compiled schemas
33-
Logger *slog.Logger // Logger for debug/error output (nil = silent)
34-
AllowXMLBodyValidation bool // Allows to convert XML to JSON when validating a request/response body.
24+
RegexEngine jsonschema.RegexpEngine
25+
RegexCache RegexCache // Enable compiled regex caching
26+
FormatAssertions bool
27+
ContentAssertions bool
28+
SecurityValidation bool
29+
OpenAPIMode bool // Enable OpenAPI-specific vocabulary validation
30+
AllowScalarCoercion bool // Enable string->boolean/number coercion
31+
Formats map[string]func(v any) error
32+
SchemaCache cache.SchemaCache // Optional cache for compiled schemas
33+
Logger *slog.Logger // Logger for debug/error output (nil = silent)
34+
AllowXMLBodyValidation bool // Allows to convert XML to JSON for validating a request/response body.
35+
AllowURLEncodedBodyValidation bool // Allows to convert URL Encoded to JSON for validating a request/response body.
3536

3637
// strict mode options - detect undeclared properties even when additionalProperties: true
3738
StrictMode bool // Enable strict property validation
@@ -77,6 +78,7 @@ func WithExistingOpts(options *ValidationOptions) Option {
7778
o.SchemaCache = options.SchemaCache
7879
o.Logger = options.Logger
7980
o.AllowXMLBodyValidation = options.AllowXMLBodyValidation
81+
o.AllowURLEncodedBodyValidation = options.AllowURLEncodedBodyValidation
8082
o.StrictMode = options.StrictMode
8183
o.StrictIgnorePaths = options.StrictIgnorePaths
8284
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
@@ -171,6 +173,14 @@ func WithXmlBodyValidation() Option {
171173
}
172174
}
173175

176+
// WithURLEncodedBodyValidation enables converting an URL Encoded body to a JSON when validating the schema from a request and response body
177+
// The default option is set to false
178+
func WithURLEncodedBodyValidation() Option {
179+
return func(o *ValidationOptions) {
180+
o.AllowURLEncodedBodyValidation = true
181+
}
182+
}
183+
174184
// WithSchemaCache sets a custom cache implementation or disables caching if nil.
175185
// Pass nil to disable schema caching and skip cache warming during validator initialization.
176186
// The default cache is a thread-safe sync.Map wrapper.

config/config_test.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ func TestNewValidationOptions_Defaults(t *testing.T) {
1919
assert.False(t, opts.FormatAssertions)
2020
assert.False(t, opts.ContentAssertions)
2121
assert.True(t, opts.SecurityValidation)
22-
assert.True(t, opts.OpenAPIMode) // Default is true
23-
assert.False(t, opts.AllowScalarCoercion) // Default is false
24-
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
22+
assert.True(t, opts.OpenAPIMode) // Default is true
23+
assert.False(t, opts.AllowScalarCoercion) // Default is false
24+
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
25+
assert.False(t, opts.AllowURLEncodedBodyValidation) // Default is false
2526
assert.Nil(t, opts.RegexEngine)
2627
assert.Nil(t, opts.RegexCache)
2728
}
@@ -97,12 +98,13 @@ func TestWithExistingOpts(t *testing.T) {
9798
// Create original options with all settings enabled
9899
var testEngine jsonschema.RegexpEngine = nil
99100
original := &ValidationOptions{
100-
RegexEngine: testEngine,
101-
RegexCache: &sync.Map{},
102-
FormatAssertions: true,
103-
AllowXMLBodyValidation: true,
104-
ContentAssertions: true,
105-
SecurityValidation: false,
101+
RegexEngine: testEngine,
102+
RegexCache: &sync.Map{},
103+
FormatAssertions: true,
104+
AllowXMLBodyValidation: true,
105+
AllowURLEncodedBodyValidation: true,
106+
ContentAssertions: true,
107+
SecurityValidation: false,
106108
}
107109

108110
// Create new options using existing options
@@ -111,6 +113,7 @@ func TestWithExistingOpts(t *testing.T) {
111113
assert.Nil(t, opts.RegexEngine) // Both should be nil
112114
assert.NotNil(t, opts.RegexCache)
113115
assert.Equal(t, original.AllowXMLBodyValidation, opts.AllowXMLBodyValidation)
116+
assert.Equal(t, original.AllowURLEncodedBodyValidation, opts.AllowURLEncodedBodyValidation)
114117
assert.Equal(t, original.FormatAssertions, opts.FormatAssertions)
115118
assert.Equal(t, original.ContentAssertions, opts.ContentAssertions)
116119
assert.Equal(t, original.SecurityValidation, opts.SecurityValidation)
@@ -189,6 +192,14 @@ func TestWithExistingOpts_PartialOverride(t *testing.T) {
189192
assert.False(t, opts.SecurityValidation) // From original
190193
}
191194

195+
func TestWithUrlEncodedBodyValidation(t *testing.T) {
196+
opts := NewValidationOptions(
197+
WithURLEncodedBodyValidation(),
198+
)
199+
200+
assert.True(t, opts.AllowURLEncodedBodyValidation)
201+
}
202+
192203
func TestComplexScenario(t *testing.T) {
193204
// Test a complex real-world scenario
194205
var mockEngine jsonschema.RegexpEngine = nil

errors/parameters_howtofix.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@ const (
1515
HowToFixInvalidXml string = "Ensure xml is well-formed and matches schema structure"
1616
HowToFixXmlPrefix string = "Make sure to prepend the correct prefix '%s' to the declared fields"
1717
HowToFixXmlNamespace string = "Make sure to declare the 'xmlns:%s' with the correct namespace URI"
18+
HowToFixFormDataReservedCharacters string = "Make sure to correcly encode specials characters to percent encoding, or set allowReserved to true"
1819
HowToFixInvalidSchema string = "Ensure that the object being submitted, matches the schema correctly"
20+
HowToFixInvalidTypeEncoding string = "Ensure that the object being submitted matches the property encoding Content-Type"
1921
HowToFixParamInvalidSpaceDelimitedObjectExplode string = "When using 'explode' with space delimited parameters, " +
2022
"they should be separated by spaces. For example: '%s'"
2123
HowToFixParamInvalidPipeDelimitedObjectExplode string = "When using 'explode' with pipe delimited parameters, " +
2224
"they should be separated by pipes '|'. For example: '%s'"
2325
HowToFixParamInvalidDeepObjectMultipleValues string = "There can only be a single value per property name, " +
2426
"deepObject parameters should contain the property key in square brackets next to the parameter name. For example: '%s'"
2527
HowToFixInvalidJSON string = "The JSON submitted is invalid, please check the syntax"
28+
HowToFixInvalidUrlEncoded string = "Ensure URL Encoded submitted is well-formed and matches schema structure"
2629
HowToFixDecodingError string = "The object can't be decoded, so make sure it's being encoded correctly according to the spec."
2730
HowToFixInvalidContentType string = "The content type is invalid, Use one of the %d supported types for this operation: %s"
2831
HowToFixInvalidResponseCode string = "The service is responding with a code that is not defined in the spec, fix the service or add the code to the specification"

errors/urlencoded_errors.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package errors
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pb33f/libopenapi-validator/helpers"
7+
"github.com/pb33f/libopenapi/datamodel/high/base"
8+
)
9+
10+
func InvalidURLEncodedParsing(reason, referenceObject string) *ValidationError {
11+
return &ValidationError{
12+
ValidationType: helpers.URLEncodedValidation,
13+
ValidationSubType: helpers.Schema,
14+
Message: "Unable to parse form-urlencoded body",
15+
Reason: fmt.Sprintf("failed to parse form-urlencoded: %s", reason),
16+
SchemaValidationErrors: []*SchemaValidationFailure{{
17+
Reason: reason,
18+
Location: "url encoded parsing",
19+
ReferenceSchema: "",
20+
ReferenceObject: referenceObject,
21+
}},
22+
HowToFix: HowToFixInvalidUrlEncoded,
23+
}
24+
}
25+
26+
func InvalidTypeEncoding(schema *base.Schema, name, contentType string) *ValidationError {
27+
line := 1
28+
col := 0
29+
if low := schema.GoLow(); low != nil && low.Type.KeyNode != nil {
30+
line = low.Type.KeyNode.Line
31+
col = low.Type.KeyNode.Column
32+
}
33+
34+
return &ValidationError{
35+
ValidationType: helpers.URLEncodedValidation,
36+
ValidationSubType: helpers.InvalidTypeEncoding,
37+
Message: fmt.Sprintf("The value '%s' could not be parsed to the defined encoding", name),
38+
Reason: fmt.Sprintf("The value '%s' is encoded as '%s' in the schema, however the value could not be parsed", name, contentType),
39+
SpecLine: line,
40+
SpecCol: col,
41+
Context: schema,
42+
HowToFix: HowToFixInvalidTypeEncoding,
43+
}
44+
}
45+
46+
func ReservedURLEncodedValue(schema *base.Schema, name, value string) *ValidationError {
47+
line := 1
48+
col := 0
49+
if schema != nil {
50+
if low := schema.GoLow(); low != nil && low.Type.KeyNode != nil {
51+
line = low.Type.KeyNode.Line
52+
col = low.Type.KeyNode.Column
53+
}
54+
}
55+
56+
return &ValidationError{
57+
ValidationType: helpers.URLEncodedValidation,
58+
ValidationSubType: helpers.ReservedValues,
59+
Message: fmt.Sprintf("Form value '%s' contains reserved characters", name),
60+
Reason: fmt.Sprintf("The form value '%s' contains reserved characters but allowReserved is false. Value: '%s'", name, value),
61+
SpecLine: line,
62+
SpecCol: col,
63+
Context: schema,
64+
HowToFix: HowToFixFormDataReservedCharacters,
65+
}
66+
}

errors/urlencoded_errors_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package errors
2+
3+
import (
4+
"testing"
5+
6+
"github.com/pb33f/libopenapi"
7+
"github.com/pb33f/libopenapi-validator/helpers"
8+
"github.com/pb33f/libopenapi/datamodel/high/base"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func getURLEncodingTestSchema() *base.Schema {
13+
spec := `openapi: 3.0.0
14+
paths:
15+
/pet:
16+
get:
17+
responses:
18+
'200':
19+
content:
20+
application/x-www-form-urlencoded:
21+
encoding:
22+
animal:
23+
contentType: application/json
24+
schema:
25+
type: object
26+
properties:
27+
animal:
28+
type: object`
29+
30+
doc, _ := libopenapi.NewDocument([]byte(spec))
31+
v3Doc, _ := doc.BuildV3Model()
32+
33+
return v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200").
34+
Content.GetOrZero("application/x-www-form-urlencoded").Schema.Schema()
35+
}
36+
37+
func TestInvalidURLEncodedParsing(t *testing.T) {
38+
err := InvalidURLEncodedParsing("no data sent", "invalid-formdata")
39+
40+
assert.NotNil(t, (*err))
41+
assert.Equal(t, (*err).SchemaValidationErrors[0].Location, "url encoded parsing")
42+
assert.Equal(t, helpers.Schema, (*err).ValidationSubType)
43+
}
44+
45+
func TestInvalidTypeEncoding(t *testing.T) {
46+
err := InvalidTypeEncoding(getURLEncodingTestSchema(), "animal", helpers.JSONContentType)
47+
48+
assert.NotNil(t, (*err))
49+
assert.Equal(t, helpers.InvalidTypeEncoding, (*err).ValidationSubType)
50+
}
51+
52+
func TestReservedURLEncodedValue(t *testing.T) {
53+
err := ReservedURLEncodedValue(getURLEncodingTestSchema(), "animal", "!")
54+
55+
assert.NotNil(t, (*err))
56+
assert.Equal(t, helpers.ReservedValues, (*err).ValidationSubType)
57+
}

errors/xml_errors.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func InvalidNamespace(schema *base.Schema, namespace, expectedNamespace, prefix
8888
}
8989
}
9090

91-
func InvalidXmlParsing(reason, referenceObject string) *ValidationError {
91+
func InvalidXMLParsing(reason, referenceObject string) *ValidationError {
9292
return &ValidationError{
9393
ValidationType: helpers.XmlValidation,
9494
ValidationSubType: helpers.Schema,

errors/xml_errors_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func TestInvalidNamespaceError(t *testing.T) {
6969
}
7070

7171
func TestInvalidParsing(t *testing.T) {
72-
err := InvalidXmlParsing("no data sent", "invalid-xml")
72+
err := InvalidXMLParsing("no data sent", "invalid-xml")
7373

7474
assert.NotNil(t, (*err))
7575
assert.Equal(t, (*err).SchemaValidationErrors[0].Location, "xml parsing")

helpers/constants.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const (
1414
XmlValidation = "xmlValidation"
1515
XmlValidationPrefix = "prefix"
1616
XmlValidationNamespace = "namespace"
17+
URLEncodedValidation = "urlEncodedValidation"
18+
InvalidTypeEncoding = "invalidTypeEncoding"
19+
ReservedValues = "reservedValues"
1720
Schema = "schema"
1821
ResponseBodyValidation = "response"
1922
RequestBodyContentType = "contentType"
@@ -51,6 +54,7 @@ const (
5154
Form = "form"
5255
Query = "query"
5356
JSONContentType = "application/json"
57+
URLEncodedContentType = "application/x-www-form-urlencoded"
5458
JSONType = "json"
5559
ContentTypeHeader = "Content-Type"
5660
AuthorizationHeader = "Authorization"

requests/validate_body.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,17 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
7878
// extract schema from media type
7979
schema := mediaType.Schema.Schema()
8080

81-
if !strings.Contains(strings.ToLower(contentType), helpers.JSONType) {
82-
// we currently only support JSON and XML validation for request bodies
83-
// this will capture *everything* that contains some form of 'json' in the content type
84-
if !v.options.AllowXMLBodyValidation || !schema_validation.IsXMLContentType(contentType) {
81+
isJson := strings.Contains(strings.ToLower(contentType), helpers.JSONType)
82+
83+
// we currently only support JSON, XML and URLEncoded validation for request bodies
84+
if !isJson {
85+
isXml := schema_validation.IsXMLContentType(contentType)
86+
isUrlEncoded := schema_validation.IsURLEncodedContentType(contentType)
87+
88+
xmlValid := isXml && v.options.AllowXMLBodyValidation
89+
urlEncodedValid := isUrlEncoded && v.options.AllowURLEncodedBodyValidation
90+
91+
if !xmlValid && !urlEncodedValid {
8592
return true, nil
8693
}
8794

@@ -90,15 +97,22 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
9097
_ = request.Body.Close()
9198

9299
stringedBody := string(requestBody)
93-
jsonBody, prevalidationErrors := schema_validation.TransformXMLToSchemaJSON(stringedBody, schema)
100+
var jsonBody any
101+
var prevalidationErrors []*errors.ValidationError
102+
103+
switch {
104+
case xmlValid:
105+
jsonBody, prevalidationErrors = schema_validation.TransformXMLToSchemaJSON(stringedBody, schema)
106+
case urlEncodedValid:
107+
jsonBody, prevalidationErrors = schema_validation.TransformURLEncodedToSchemaJSON(stringedBody, schema, mediaType.Encoding)
108+
}
109+
94110
if len(prevalidationErrors) > 0 {
95111
return false, prevalidationErrors
96112
}
97113

98-
transformedBytes, err := json.Marshal(jsonBody)
99-
if err != nil {
100-
return false, []*errors.ValidationError{errors.InvalidXmlParsing(err.Error(), stringedBody)}
101-
}
114+
// If prevalidationErrors has no items, jsonBody is a valid JSON structure
115+
transformedBytes, _ := json.Marshal(jsonBody)
102116

103117
request.Body = io.NopCloser(bytes.NewBuffer(transformedBytes))
104118
}

requests/validate_body_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,51 @@ paths:
16171617
assert.Equal(t, errors[0].Message, "xml example is malformed")
16181618
}
16191619

1620+
func TestValidateBody_URLEncodedRequest(t *testing.T) {
1621+
spec := `openapi: 3.1.0
1622+
paths:
1623+
/burgers/createBurger:
1624+
post:
1625+
requestBody:
1626+
content:
1627+
application/x-www-form-urlencoded:
1628+
schema:
1629+
type: object
1630+
required:
1631+
- name
1632+
properties:
1633+
name:
1634+
type: string
1635+
patties:
1636+
type: integer`
1637+
1638+
doc, _ := libopenapi.NewDocument([]byte(spec))
1639+
1640+
m, _ := doc.BuildV3Model()
1641+
v := NewRequestBodyValidator(&m.Model, config.WithURLEncodedBodyValidation())
1642+
1643+
body := "name=cheeseburger&patties=23"
1644+
1645+
request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
1646+
bytes.NewBuffer([]byte(body)))
1647+
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1648+
1649+
valid, errors := v.ValidateRequestBody(request)
1650+
assert.True(t, valid)
1651+
assert.Len(t, errors, 0)
1652+
1653+
body = "name=cheeseburger&patties=23.4"
1654+
1655+
request, _ = http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
1656+
bytes.NewBuffer([]byte(body)))
1657+
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1658+
1659+
valid, errors = v.ValidateRequestBody(request)
1660+
1661+
assert.False(t, valid)
1662+
assert.Len(t, errors, 1)
1663+
}
1664+
16201665
func TestValidateBody_XmlRequest(t *testing.T) {
16211666
spec := `openapi: 3.1.0
16221667
paths:

0 commit comments

Comments
 (0)