Skip to content

Commit

Permalink
v0.1.4: per-field validation added
Browse files Browse the repository at this point in the history
  • Loading branch information
wneessen committed Aug 11, 2021
1 parent c141a90 commit 41c6def
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 60 deletions.
62 changes: 40 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ API that can be accessed via JavaScript `Fetch()` or `XMLHttpRequest`.
* Per-form mail server configuration
* hCaptcha support
* reCaptcha v2 support
* Form field type validation (text, email, number, bool)

### Planed features

* [ ] Form field-type validation
* [ ] Form body templates (possibly HTML)

## Installation
Expand Down Expand Up @@ -90,20 +90,34 @@ the JSON syntax of the form configuration is very simple, yet flexible.
"name",
"email",
"message"
],
"required_fields": [
"name",
"email"
],
"honeypot": "street"
},
"hcaptcha": {
"enabled": true,
"secret_key": "0x01234567890"
]
},
"recaptcha": {
"enabled": true,
"secret_key": "0x01234567890"
"validation": {
"hcaptcha": {
"enabled": true,
"secret_key": "0x01234567890"
},
"recaptcha": {
"enabled": true,
"secret_key": "0x01234567890"
},
"honeypot": "street",
"fields": [
{
"name": "name",
"type": "text",
"required": true
},
{
"name": "mail_addr",
"type": "email",
"required": true
},
{
"name": "terms_checked",
"required": true
}
]
},
"server": {
"host": "mail.example.com",
Expand All @@ -123,14 +137,18 @@ the JSON syntax of the form configuration is very simple, yet flexible.
* `content (type: struct)`: The struct for the mail content configuration
* `subject (type: string)`: Subject for the mail notification of the form submission
* `fields (type: []string)`: List of field names that should show up in the mail notification
* `required_fields (type: []string)`: List of field names that are required to submitted
* `honeypot (type: string)`: Name of the honeypot field, that is expected to be empty (Anti-SPAM)
* `hcaptcha (type: struct)`: The struct for the forms hCaptcha configuration
* `enabled (type: bool)`: Enable hCaptcha challenge-response validation
* `secret_key (type: string)`: Your hCaptcha secret key
* `recaptcha (type: struct)`: The struct for the forms reCaptcha configuration
* `enabled (type: bool)`: Enable reCaptcha challenge-response validation
* `secret_key (type: string)`: Your reCaptcha secret key
* `validation (type: struct)`: The struct for the form validation configuration
* `hcaptcha (type: struct)`: The struct for the forms hCaptcha configuration
* `enabled (type: bool)`: Enable hCaptcha challenge-response validation
* `secret_key (type: string)`: Your hCaptcha secret key
* `recaptcha (type: struct)`: The struct for the forms reCaptcha configuration
* `enabled (type: bool)`: Enable reCaptcha challenge-response validation
* `secret_key (type: string)`: Your reCaptcha secret key
* `honeypot (type: string)`: Name of the honeypot field, that is expected to be empty (Anti-SPAM)
* `fields (type: []struct)`: Array of single field validation configurations
* `name (type: string)`: Field validation identifier
* `type (type: string)`: Type of validation to run on field (text, email, nummber, bool)
* `required (type: boolean)`: If set to true, the field is required
* `server (type: struct)`: The struct for the forms mail server configuration
* `host (type: string)`: Hostname of the sending mail server
* `port (type: uint32)`: Port to connect to on the sending mail server
Expand Down
34 changes: 20 additions & 14 deletions apirequest/sendform.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,43 +75,49 @@ func (a *ApiRequest) SendFormValidate(r *http.Request) (int, error) {

// Make sure all required fields are set
// Maybe we can build some kind of validator here
var missingFields []string
for _, f := range formObj.Content.RequiredFields {
if r.Form.Get(f) == "" {
l.Warnf("Form includes a honeypot field which is not empty. Denying request")
missingFields = append(missingFields, f)
var invalidFields []string
fieldError := make(map[string]string)
for _, f := range formObj.Validation.Fields {
if err := validation.Field(r, &f); err != nil {
invalidFields = append(invalidFields, f.Name)
fieldError[f.Name] = err.Error()
}
}
if len(missingFields) > 0 {
l.Errorf("Required fields missing: %s", strings.Join(missingFields, ", "))
return 400, fmt.Errorf("required fields missing: %s", strings.Join(missingFields, ", "))
if len(invalidFields) > 0 {
l.Errorf("Form field validation failed: %s", strings.Join(invalidFields, ", "))
var errorMsg []string
for _, f := range invalidFields {
errorMsg = append(errorMsg, fmt.Sprintf("%s: %s", f, fieldError[f]))
}
return 400, fmt.Errorf("field(s) validation failed: %s", strings.Join(errorMsg, ", "))
}

// Anti-SPAM honeypot handling
if formObj.Content.Honeypot != nil {
if r.Form.Get(*formObj.Content.Honeypot) != "" {
if formObj.Validation.Honeypot != nil {
if r.Form.Get(*formObj.Validation.Honeypot) != "" {
l.Warnf("Form includes a honeypot field which is not empty. Denying request")
return 400, fmt.Errorf("invalid form data")
}
}

// Validate hCaptcha if enabled
if formObj.Hcaptcha.Enabled {
if formObj.Validation.Hcaptcha.Enabled {
hcapResponse := r.Form.Get("h-captcha-response")
if hcapResponse == "" {
return 400, fmt.Errorf("missing hCaptcha response")
}
if ok := validation.HcaptchaValidate(hcapResponse, formObj.Hcaptcha.SecretKey); !ok {
if ok := validation.Hcaptcha(hcapResponse, formObj.Validation.Hcaptcha.SecretKey); !ok {
return 400, fmt.Errorf("hCaptcha challenge-response validation failed")
}
}

// Validate reCaptcha if enabled
if formObj.Recaptcha.Enabled {
if formObj.Validation.Recaptcha.Enabled {
recapResponse := r.Form.Get("g-recaptcha-response")
if recapResponse == "" {
return 400, fmt.Errorf("missing reCaptcha response")
}
if ok := validation.RecaptchaValidate(recapResponse, formObj.Recaptcha.SecretKey); !ok {
if ok := validation.Recaptcha(recapResponse, formObj.Validation.Recaptcha.SecretKey); !ok {
return 400, fmt.Errorf("reCaptcha challenge-response validation failed")
}
}
Expand Down
50 changes: 45 additions & 5 deletions etc/js-mailer/forms/1.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,53 @@
{
"id": 1,
"id": "test_form",
"secret": "SuperSecretsString",
"recipients": ["[email protected]"],
"recipients": [
"[email protected]"
],
"sender": "[email protected]",
"domains": ["www.example.com", "example.com"],
"domains": [
"www.example.com",
"example.com"
],
"validation": {
"hcaptcha": {
"enabled": false,
"secret_key": "0x1234567890"
},
"recaptcha": {
"enabled": false,
"secret_key": "0x1234567890"
},
"fields": [
{
"name": "name",
"type": "text",
"required": true
},
{
"name": "email",
"type": "email",
"required": true
},
{
"name": "age",
"type": "number",
"required": true
},
{
"name": "message",
"required": true
}
]
},
"content": {
"subject": "New message through the www.example.com contact form",
"fields": ["name", "email", "message"],
"required_fields": ["name", "email"]
"fields": [
"name",
"email",
"age",
"message"
]
},
"server": {
"host": "mail.example.com",
Expand Down
31 changes: 20 additions & 11 deletions form/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ type Form struct {
Recipients []string `fig:"recipients" validate:"required"`
Sender string `fig:"sender" validate:"required"`
Domains []string `fig:"domains" validate:"required"`
Hcaptcha struct {
Enabled bool `fig:"enabled"`
SecretKey string `fig:"secret_key"`
}
Recaptcha struct {
Enabled bool `fig:"enabled"`
SecretKey string `fig:"secret_key"`
Validation struct {
Hcaptcha struct {
Enabled bool `fig:"enabled"`
SecretKey string `fig:"secret_key"`
}
Recaptcha struct {
Enabled bool `fig:"enabled"`
SecretKey string `fig:"secret_key"`
}
Fields []ValidationField `fig:"fields"`
Honeypot *string `fig:"honeypot"`
}
Content struct {
Subject string
Fields []string
RequiredFields []string `fig:"required_fields"`
Honeypot *string `fig:"honeypot"`
Subject string
Fields []string
}
Server struct {
Host string `fig:"host" validate:"required"`
Expand All @@ -39,6 +41,13 @@ type Form struct {
}
}

// ValidationField reflects the struct for a form validation field
type ValidationField struct {
Name string `fig:"name" validate:"required"`
Type string `fig:"type"`
Required bool `fig:"required"`
}

// NewForm returns a new Form object to the caller. It fails with an error when
// the form is question wasn't found or does not fulfill the syntax requirements
func NewForm(c *config.Config, i string) (Form, error) {
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

// VERSION is the global version string contstant
const VERSION = "0.1.3"
const VERSION = "0.1.4"

// serve acts as main web service server muxer/handler for incoming HTTP requests
func serve(c *config.Config) {
Expand Down
61 changes: 61 additions & 0 deletions validation/field.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package validation

import (
"fmt"
log "github.com/sirupsen/logrus"
"github.com/wneessen/js-mailer/form"
"net/http"
"regexp"
)

// Field validates the form field based on its configured type
func Field(r *http.Request, f *form.ValidationField) error {
l := log.WithFields(log.Fields{
"action": "validation.Field",
"fieldName": f.Name,
})

if f.Required && r.Form.Get(f.Name) == "" {
l.Debugf("Form is missing required field: %s", f.Name)
return fmt.Errorf("field is required, but missing")
}

switch f.Type {
case "text":
return nil
case "email":
mailRegExp, err := regexp.Compile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
if err != nil {
l.Errorf("Failed to compile email comparsion regexp: %s", err)
return nil
}
if !mailRegExp.Match([]byte(r.Form.Get(f.Name))) {
l.Debugf("Form field is expected to be of type email but does not match this requirementd: %s", f.Name)
return fmt.Errorf("field is expected to be of type email, but does not match")
}
case "number":
numRegExp, err := regexp.Compile("^[0-9]+$")
if err != nil {
l.Errorf("Failed to compile email comparsion regexp: %s", err)
return nil
}
if !numRegExp.Match([]byte(r.Form.Get(f.Name))) {
l.Debugf("Form field is expected to be of type number but does not match this requirementd: %s", f.Name)
return fmt.Errorf("field is expected to be of type number, but does not match")
}
case "bool":
boolRegExp, err := regexp.Compile("^(?i)(true|false|0|1)$")
if err != nil {
l.Errorf("Failed to compile boolean comparsion regexp: %s", err)
return nil
}
if !boolRegExp.Match([]byte(r.Form.Get(f.Name))) {
l.Debugf("Form field is expected to be of type boolean but does not match this requirementd: %s", f.Name)
return fmt.Errorf("field is expected to be of type bool, but does not match")
}
default:
return nil
}

return nil
}
6 changes: 3 additions & 3 deletions validation/hcaptcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ type HcaptchaResponseJson struct {
Hostname string `json:"hostname"`
}

// HcaptchaValidate validates the hCaptcha challenge against the hCaptcha API
func HcaptchaValidate(c, s string) bool {
// Hcaptcha validates the hCaptcha challenge against the hCaptcha API
func Hcaptcha(c, s string) bool {
l := log.WithFields(log.Fields{
"action": "validation.HcaptchaValidate",
"action": "validation.Hcaptcha",
})

// Create a HTTP request
Expand Down
8 changes: 4 additions & 4 deletions validation/recaptcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import (
"net/url"
)

// HcaptchaResponseJson reflect the API response from hCaptcha
// RecaptchaResponseJson reflect the API response from hCaptcha
type RecaptchaResponseJson struct {
Success bool `json:"success"`
ChallengeTimestamp string `json:"challenge_ts"`
Hostname string `json:"hostname"`
}

// RecaptchaValidate validates the reCaptcha challenge against the Google API
func RecaptchaValidate(c, s string) bool {
// Recaptcha validates the reCaptcha challenge against the Google API
func Recaptcha(c, s string) bool {
l := log.WithFields(log.Fields{
"action": "validation.RecaptchaValidate",
"action": "validation.Recaptcha",
})

// Create a HTTP request
Expand Down

0 comments on commit 41c6def

Please sign in to comment.