2021-04-18 15:41:13 -04:00
|
|
|
// Copyright (c) 2015-2021 MinIO, Inc.
|
|
|
|
//
|
|
|
|
// This file is part of MinIO Object Storage stack
|
|
|
|
//
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
|
|
// (at your option) any later version.
|
|
|
|
//
|
|
|
|
// This program is distributed in the hope that it will be useful
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
// GNU Affero General Public License for more details.
|
|
|
|
//
|
|
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2015-10-04 19:31:07 -04:00
|
|
|
|
2016-08-18 19:23:42 -04:00
|
|
|
package cmd
|
2015-10-02 02:51:17 -04:00
|
|
|
|
|
|
|
import (
|
2021-03-21 01:16:30 -04:00
|
|
|
"bytes"
|
2015-10-02 02:51:17 -04:00
|
|
|
"encoding/json"
|
2016-11-30 21:30:59 -05:00
|
|
|
"errors"
|
2015-10-02 02:51:17 -04:00
|
|
|
"fmt"
|
2021-03-21 01:16:30 -04:00
|
|
|
"io"
|
2016-12-02 20:00:33 -05:00
|
|
|
"net/http"
|
2015-10-02 02:51:17 -04:00
|
|
|
"reflect"
|
2016-11-30 21:30:59 -05:00
|
|
|
"strconv"
|
2016-02-15 20:42:39 -05:00
|
|
|
"strings"
|
2015-10-02 02:51:17 -04:00
|
|
|
"time"
|
2021-03-21 01:16:30 -04:00
|
|
|
|
|
|
|
"github.com/bcicen/jstream"
|
2023-09-25 15:35:25 -04:00
|
|
|
"github.com/minio/minio-go/v7/pkg/encrypt"
|
2021-03-25 16:57:57 -04:00
|
|
|
"github.com/minio/minio-go/v7/pkg/set"
|
2023-09-25 15:35:25 -04:00
|
|
|
xhttp "github.com/minio/minio/internal/http"
|
2015-10-02 02:51:17 -04:00
|
|
|
)
|
|
|
|
|
2017-01-18 15:24:34 -05:00
|
|
|
// startWithConds - map which indicates if a given condition supports starts-with policy operator
|
|
|
|
var startsWithConds = map[string]bool{
|
2018-12-28 17:04:39 -05:00
|
|
|
"$acl": true,
|
|
|
|
"$bucket": false,
|
|
|
|
"$cache-control": true,
|
|
|
|
"$content-type": true,
|
|
|
|
"$content-disposition": true,
|
|
|
|
"$content-encoding": true,
|
|
|
|
"$expires": true,
|
|
|
|
"$key": true,
|
2017-01-18 15:24:34 -05:00
|
|
|
"$success_action_redirect": true,
|
|
|
|
"$redirect": true,
|
2021-07-13 16:33:03 -04:00
|
|
|
"$success_action_status": true,
|
2017-01-18 15:24:34 -05:00
|
|
|
"$x-amz-algorithm": false,
|
|
|
|
"$x-amz-credential": false,
|
|
|
|
"$x-amz-date": false,
|
|
|
|
}
|
|
|
|
|
2023-09-25 15:35:25 -04:00
|
|
|
var postPolicyIgnoreKeys = map[string]bool{
|
|
|
|
"Policy": true,
|
|
|
|
xhttp.AmzSignature: true,
|
|
|
|
xhttp.ContentEncoding: true,
|
|
|
|
http.CanonicalHeaderKey(xhttp.AmzChecksumAlgo): true,
|
|
|
|
http.CanonicalHeaderKey(xhttp.AmzChecksumCRC32): true,
|
|
|
|
http.CanonicalHeaderKey(xhttp.AmzChecksumCRC32C): true,
|
|
|
|
http.CanonicalHeaderKey(xhttp.AmzChecksumSHA1): true,
|
|
|
|
http.CanonicalHeaderKey(xhttp.AmzChecksumSHA256): true,
|
|
|
|
http.CanonicalHeaderKey(xhttp.AmzChecksumMode): true,
|
|
|
|
}
|
|
|
|
|
2017-01-18 15:24:34 -05:00
|
|
|
// Add policy conditionals.
|
|
|
|
const (
|
|
|
|
policyCondEqual = "eq"
|
|
|
|
policyCondStartsWith = "starts-with"
|
|
|
|
policyCondContentLength = "content-length-range"
|
|
|
|
)
|
|
|
|
|
2015-10-02 02:51:17 -04:00
|
|
|
// toString - Safely convert interface to string without causing panic.
|
|
|
|
func toString(val interface{}) string {
|
|
|
|
switch v := val.(type) {
|
|
|
|
case string:
|
|
|
|
return v
|
2018-08-06 13:26:40 -04:00
|
|
|
default:
|
|
|
|
return ""
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-12-02 20:00:33 -05:00
|
|
|
// toLowerString - safely convert interface to lower string
|
|
|
|
func toLowerString(val interface{}) string {
|
|
|
|
return strings.ToLower(toString(val))
|
|
|
|
}
|
|
|
|
|
2015-10-02 02:51:17 -04:00
|
|
|
// toInteger _ Safely convert interface to integer without causing panic.
|
2016-11-30 21:30:59 -05:00
|
|
|
func toInteger(val interface{}) (int64, error) {
|
2015-10-02 02:51:17 -04:00
|
|
|
switch v := val.(type) {
|
2016-11-21 07:15:26 -05:00
|
|
|
case float64:
|
2016-11-30 21:30:59 -05:00
|
|
|
return int64(v), nil
|
2016-11-21 07:15:26 -05:00
|
|
|
case int64:
|
2016-11-30 21:30:59 -05:00
|
|
|
return v, nil
|
2016-11-21 07:15:26 -05:00
|
|
|
case int:
|
2016-11-30 21:30:59 -05:00
|
|
|
return int64(v), nil
|
|
|
|
case string:
|
|
|
|
i, err := strconv.Atoi(v)
|
|
|
|
return int64(i), err
|
2018-08-06 13:26:40 -04:00
|
|
|
default:
|
|
|
|
return 0, errors.New("Invalid number format")
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// isString - Safely check if val is of type string without causing panic.
|
|
|
|
func isString(val interface{}) bool {
|
2018-08-06 13:26:40 -04:00
|
|
|
_, ok := val.(string)
|
|
|
|
return ok
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
|
2016-10-25 02:47:03 -04:00
|
|
|
// ContentLengthRange - policy content-length-range field.
|
|
|
|
type contentLengthRange struct {
|
2016-11-21 07:15:26 -05:00
|
|
|
Min int64
|
|
|
|
Max int64
|
2016-10-25 02:47:03 -04:00
|
|
|
Valid bool // If content-length-range was part of policy
|
|
|
|
}
|
|
|
|
|
2015-10-02 02:51:17 -04:00
|
|
|
// PostPolicyForm provides strict static type conversion and validation for Amazon S3's POST policy JSON string.
|
|
|
|
type PostPolicyForm struct {
|
|
|
|
Expiration time.Time // Expiration date and time of the POST policy.
|
|
|
|
Conditions struct { // Conditional policy structure.
|
2019-09-22 17:20:49 -04:00
|
|
|
Policies []struct {
|
2015-10-02 02:51:17 -04:00
|
|
|
Operator string
|
2019-09-22 17:20:49 -04:00
|
|
|
Key string
|
2015-10-02 02:51:17 -04:00
|
|
|
Value string
|
|
|
|
}
|
2016-10-25 02:47:03 -04:00
|
|
|
ContentLengthRange contentLengthRange
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-21 01:16:30 -04:00
|
|
|
// implemented to ensure that duplicate keys in JSON
|
|
|
|
// are merged together into a single JSON key, also
|
|
|
|
// to remove any extraneous JSON bodies.
|
|
|
|
//
|
|
|
|
// Go stdlib doesn't support parsing JSON with duplicate
|
|
|
|
// keys, so we need to use this technique to merge the
|
|
|
|
// keys.
|
2021-03-25 16:57:57 -04:00
|
|
|
func sanitizePolicy(r io.Reader) (io.Reader, error) {
|
2021-03-21 01:16:30 -04:00
|
|
|
var buf bytes.Buffer
|
|
|
|
e := json.NewEncoder(&buf)
|
2021-03-25 16:57:57 -04:00
|
|
|
d := jstream.NewDecoder(r, 0).ObjectAsKVS()
|
|
|
|
sset := set.NewStringSet()
|
2021-03-21 01:16:30 -04:00
|
|
|
for mv := range d.Stream() {
|
2021-03-25 16:57:57 -04:00
|
|
|
var kvs jstream.KVS
|
|
|
|
if mv.ValueType == jstream.Object {
|
|
|
|
// This is a JSON object type (that preserves key order)
|
|
|
|
kvs = mv.Value.(jstream.KVS)
|
|
|
|
for _, kv := range kvs {
|
|
|
|
if sset.Contains(kv.Key) {
|
|
|
|
// Reject duplicate conditions or expiration.
|
|
|
|
return nil, fmt.Errorf("input policy has multiple %s, please fix your client code", kv.Key)
|
|
|
|
}
|
|
|
|
sset.Add(kv.Key)
|
|
|
|
}
|
|
|
|
e.Encode(kvs)
|
|
|
|
}
|
2021-03-21 01:16:30 -04:00
|
|
|
}
|
|
|
|
return &buf, d.Err()
|
|
|
|
}
|
|
|
|
|
2019-07-10 01:18:43 -04:00
|
|
|
// parsePostPolicyForm - Parse JSON policy string into typed PostPolicyForm structure.
|
2021-03-25 16:57:57 -04:00
|
|
|
func parsePostPolicyForm(r io.Reader) (PostPolicyForm, error) {
|
|
|
|
reader, err := sanitizePolicy(r)
|
2021-03-21 01:16:30 -04:00
|
|
|
if err != nil {
|
|
|
|
return PostPolicyForm{}, err
|
|
|
|
}
|
|
|
|
|
2021-03-25 16:57:57 -04:00
|
|
|
d := json.NewDecoder(reader)
|
2021-03-21 01:16:30 -04:00
|
|
|
|
2015-10-02 02:51:17 -04:00
|
|
|
// Convert po into interfaces and
|
|
|
|
// perform strict type conversion using reflection.
|
|
|
|
var rawPolicy struct {
|
|
|
|
Expiration string `json:"expiration"`
|
|
|
|
Conditions []interface{} `json:"conditions"`
|
|
|
|
}
|
|
|
|
|
2021-03-21 01:16:30 -04:00
|
|
|
d.DisallowUnknownFields()
|
|
|
|
if err := d.Decode(&rawPolicy); err != nil {
|
|
|
|
return PostPolicyForm{}, err
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
parsedPolicy := PostPolicyForm{}
|
|
|
|
|
|
|
|
// Parse expiry time.
|
2016-04-29 17:24:10 -04:00
|
|
|
parsedPolicy.Expiration, err = time.Parse(time.RFC3339Nano, rawPolicy.Expiration)
|
|
|
|
if err != nil {
|
2021-03-21 01:16:30 -04:00
|
|
|
return PostPolicyForm{}, err
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// Parse conditions.
|
|
|
|
for _, val := range rawPolicy.Conditions {
|
|
|
|
switch condt := val.(type) {
|
|
|
|
case map[string]interface{}: // Handle key:value map types.
|
|
|
|
for k, v := range condt {
|
|
|
|
if !isString(v) { // Pre-check value type.
|
|
|
|
// All values must be of type string.
|
2016-11-15 21:14:23 -05:00
|
|
|
return parsedPolicy, fmt.Errorf("Unknown type %s of conditional field value %s found in POST policy form", reflect.TypeOf(condt).String(), condt)
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
// {"acl": "public-read" } is an alternate way to indicate - [ "eq", "$acl", "public-read" ]
|
|
|
|
// In this case we will just collapse this into "eq" for all use cases.
|
2019-09-22 17:20:49 -04:00
|
|
|
parsedPolicy.Conditions.Policies = append(parsedPolicy.Conditions.Policies, struct {
|
2015-10-02 02:51:17 -04:00
|
|
|
Operator string
|
2019-09-22 17:20:49 -04:00
|
|
|
Key string
|
2015-10-02 02:51:17 -04:00
|
|
|
Value string
|
|
|
|
}{
|
2019-09-22 17:20:49 -04:00
|
|
|
policyCondEqual, "$" + strings.ToLower(k), toString(v),
|
|
|
|
})
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
case []interface{}: // Handle array types.
|
|
|
|
if len(condt) != 3 { // Return error if we have insufficient elements.
|
2016-11-15 21:14:23 -05:00
|
|
|
return parsedPolicy, fmt.Errorf("Malformed conditional fields %s of type %s found in POST policy form", condt, reflect.TypeOf(condt).String())
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
2016-12-02 20:00:33 -05:00
|
|
|
switch toLowerString(condt[0]) {
|
2017-01-18 15:24:34 -05:00
|
|
|
case policyCondEqual, policyCondStartsWith:
|
2015-10-02 02:51:17 -04:00
|
|
|
for _, v := range condt { // Pre-check all values for type.
|
|
|
|
if !isString(v) {
|
|
|
|
// All values must be of type string.
|
2016-11-15 21:14:23 -05:00
|
|
|
return parsedPolicy, fmt.Errorf("Unknown type %s of conditional field value %s found in POST policy form", reflect.TypeOf(condt).String(), condt)
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
}
|
2016-12-02 20:00:33 -05:00
|
|
|
operator, matchType, value := toLowerString(condt[0]), toLowerString(condt[1]), toString(condt[2])
|
2019-09-22 17:20:49 -04:00
|
|
|
if !strings.HasPrefix(matchType, "$") {
|
|
|
|
return parsedPolicy, fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]", operator, matchType, value)
|
|
|
|
}
|
|
|
|
parsedPolicy.Conditions.Policies = append(parsedPolicy.Conditions.Policies, struct {
|
2015-10-02 02:51:17 -04:00
|
|
|
Operator string
|
2019-09-22 17:20:49 -04:00
|
|
|
Key string
|
2015-10-02 02:51:17 -04:00
|
|
|
Value string
|
|
|
|
}{
|
2019-09-22 17:20:49 -04:00
|
|
|
operator, matchType, value,
|
|
|
|
})
|
2017-01-18 15:24:34 -05:00
|
|
|
case policyCondContentLength:
|
2016-11-30 21:30:59 -05:00
|
|
|
min, err := toInteger(condt[1])
|
|
|
|
if err != nil {
|
|
|
|
return parsedPolicy, err
|
|
|
|
}
|
|
|
|
|
|
|
|
max, err := toInteger(condt[2])
|
|
|
|
if err != nil {
|
|
|
|
return parsedPolicy, err
|
|
|
|
}
|
|
|
|
|
2016-11-21 07:15:26 -05:00
|
|
|
parsedPolicy.Conditions.ContentLengthRange = contentLengthRange{
|
2016-11-30 21:30:59 -05:00
|
|
|
Min: min,
|
|
|
|
Max: max,
|
2016-11-21 07:15:26 -05:00
|
|
|
Valid: true,
|
|
|
|
}
|
2015-10-02 02:51:17 -04:00
|
|
|
default:
|
|
|
|
// Condition should be valid.
|
2016-11-15 21:14:23 -05:00
|
|
|
return parsedPolicy, fmt.Errorf("Unknown type %s of conditional field value %s found in POST policy form",
|
|
|
|
reflect.TypeOf(condt).String(), condt)
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
default:
|
2016-11-15 21:14:23 -05:00
|
|
|
return parsedPolicy, fmt.Errorf("Unknown field %s of type %s found in POST policy form",
|
|
|
|
condt, reflect.TypeOf(condt).String())
|
2015-10-02 02:51:17 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return parsedPolicy, nil
|
|
|
|
}
|
2016-02-15 20:42:39 -05:00
|
|
|
|
2016-12-02 20:00:33 -05:00
|
|
|
// checkPolicyCond returns a boolean to indicate if a condition is satisified according
|
|
|
|
// to the passed operator
|
|
|
|
func checkPolicyCond(op string, input1, input2 string) bool {
|
|
|
|
switch op {
|
2017-01-18 15:24:34 -05:00
|
|
|
case policyCondEqual:
|
2016-12-02 20:00:33 -05:00
|
|
|
return input1 == input2
|
2017-01-18 15:24:34 -05:00
|
|
|
case policyCondStartsWith:
|
2016-12-02 20:00:33 -05:00
|
|
|
return strings.HasPrefix(input1, input2)
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2016-03-12 19:08:15 -05:00
|
|
|
// checkPostPolicy - apply policy conditions and validate input values.
|
2016-12-02 20:00:33 -05:00
|
|
|
// (http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html)
|
2019-03-05 15:10:47 -05:00
|
|
|
func checkPostPolicy(formValues http.Header, postPolicyForm PostPolicyForm) error {
|
2016-12-02 20:00:33 -05:00
|
|
|
// Check if policy document expiry date is still not reached
|
2017-03-18 14:28:41 -04:00
|
|
|
if !postPolicyForm.Expiration.After(UTCNow()) {
|
2019-03-05 15:10:47 -05:00
|
|
|
return fmt.Errorf("Invalid according to Policy: Policy expired")
|
2016-02-15 20:42:39 -05:00
|
|
|
}
|
2023-09-25 15:35:25 -04:00
|
|
|
// check all formValues appear in postPolicyForm or return error. #https://github.com/minio/minio/issues/17391
|
|
|
|
checkHeader := map[string][]string{}
|
|
|
|
ignoreKeys := map[string]bool{}
|
|
|
|
for key, value := range formValues {
|
|
|
|
switch {
|
|
|
|
case ignoreKeys[key], postPolicyIgnoreKeys[key], strings.HasPrefix(key, encrypt.SseGenericHeader):
|
|
|
|
continue
|
|
|
|
case strings.HasPrefix(key, "X-Amz-Ignore-"):
|
|
|
|
ignoreKey := strings.Replace(key, "X-Amz-Ignore-", "", 1)
|
|
|
|
ignoreKeys[ignoreKey] = true
|
|
|
|
// if it have already
|
|
|
|
delete(checkHeader, ignoreKey)
|
|
|
|
default:
|
|
|
|
checkHeader[key] = value
|
|
|
|
}
|
|
|
|
}
|
2019-04-02 02:45:32 -04:00
|
|
|
// map to store the metadata
|
|
|
|
metaMap := make(map[string]string)
|
2019-09-22 17:20:49 -04:00
|
|
|
for _, policy := range postPolicyForm.Conditions.Policies {
|
|
|
|
if strings.HasPrefix(policy.Key, "$x-amz-meta-") {
|
|
|
|
formCanonicalName := http.CanonicalHeaderKey(strings.TrimPrefix(policy.Key, "$"))
|
|
|
|
metaMap[formCanonicalName] = policy.Value
|
2019-04-02 02:45:32 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
// Check if any extra metadata field is passed as input
|
|
|
|
for key := range formValues {
|
|
|
|
if strings.HasPrefix(key, "X-Amz-Meta-") {
|
|
|
|
if _, ok := metaMap[key]; !ok {
|
|
|
|
return fmt.Errorf("Invalid according to Policy: Extra input fields: %s", key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2016-12-02 20:00:33 -05:00
|
|
|
|
|
|
|
// Flag to indicate if all policies conditions are satisfied
|
2019-10-15 21:35:41 -04:00
|
|
|
var condPassed bool
|
2016-12-02 20:00:33 -05:00
|
|
|
|
|
|
|
// Iterate over policy conditions and check them against received form fields
|
2019-09-22 17:20:49 -04:00
|
|
|
for _, policy := range postPolicyForm.Conditions.Policies {
|
2016-12-02 20:00:33 -05:00
|
|
|
// Form fields names are in canonical format, convert conditions names
|
|
|
|
// to canonical for simplification purpose, so `$key` will become `Key`
|
2019-09-22 17:20:49 -04:00
|
|
|
formCanonicalName := http.CanonicalHeaderKey(strings.TrimPrefix(policy.Key, "$"))
|
2016-12-02 20:00:33 -05:00
|
|
|
// Operator for the current policy condition
|
2019-09-22 17:20:49 -04:00
|
|
|
op := policy.Operator
|
2023-09-25 15:35:25 -04:00
|
|
|
// Multiple values should not occur
|
|
|
|
if len(checkHeader[formCanonicalName]) >= 2 {
|
|
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]. FormValues have multiple values: [%s]", op, policy.Key, policy.Value, strings.Join(checkHeader[formCanonicalName], ", "))
|
|
|
|
}
|
2016-12-02 20:00:33 -05:00
|
|
|
// If the current policy condition is known
|
2019-09-22 17:20:49 -04:00
|
|
|
if startsWithSupported, condFound := startsWithConds[policy.Key]; condFound {
|
2016-12-02 20:00:33 -05:00
|
|
|
// Check if the current condition supports starts-with operator
|
2017-01-18 15:24:34 -05:00
|
|
|
if op == policyCondStartsWith && !startsWithSupported {
|
2019-03-05 15:10:47 -05:00
|
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed")
|
2016-12-02 20:00:33 -05:00
|
|
|
}
|
|
|
|
// Check if current policy condition is satisfied
|
2019-09-22 17:20:49 -04:00
|
|
|
condPassed = checkPolicyCond(op, formValues.Get(formCanonicalName), policy.Value)
|
2019-03-05 15:10:47 -05:00
|
|
|
if !condPassed {
|
|
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed")
|
|
|
|
}
|
2021-11-16 12:28:29 -05:00
|
|
|
} else if strings.HasPrefix(policy.Key, "$x-amz-meta-") || strings.HasPrefix(policy.Key, "$x-amz-") {
|
2016-12-02 20:00:33 -05:00
|
|
|
// This covers all conditions X-Amz-Meta-* and X-Amz-*
|
2021-11-16 12:28:29 -05:00
|
|
|
// Check if policy condition is satisfied
|
|
|
|
condPassed = checkPolicyCond(op, formValues.Get(formCanonicalName), policy.Value)
|
|
|
|
if !condPassed {
|
|
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]", op, policy.Key, policy.Value)
|
2016-12-02 20:00:33 -05:00
|
|
|
}
|
2016-02-15 20:42:39 -05:00
|
|
|
}
|
2023-09-25 15:35:25 -04:00
|
|
|
delete(checkHeader, formCanonicalName)
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(checkHeader) != 0 {
|
|
|
|
logKeys := make([]string, 0, len(checkHeader))
|
|
|
|
for key := range checkHeader {
|
|
|
|
logKeys = append(logKeys, key)
|
|
|
|
}
|
|
|
|
return fmt.Errorf("Each form field that you specify in a form (except %s) must appear in the list of conditions.", strings.Join(logKeys, ", "))
|
2016-02-15 20:42:39 -05:00
|
|
|
}
|
2016-12-02 20:00:33 -05:00
|
|
|
|
2019-03-05 15:10:47 -05:00
|
|
|
return nil
|
2016-02-15 20:42:39 -05:00
|
|
|
}
|