diff --git a/README.md b/README.md index 19b3d41..5eeabda 100644 --- a/README.md +++ b/README.md @@ -155,16 +155,52 @@ This gives you fine-grained control over image processing in your Caddy server. ### Example with `on_fail` and Security Configuration ```plaintext -localhost { - image_processor { - on_fail bypass # Default value +localhost:80 { + import common + root test-dataset + file_server + image_processor { + + # Serve original image if image in unprocessable + on_fail bypass + + # Return 500 Internal Server Error if processing fails + # on_fail abort + + security { - on_security_fail ignore # Default value + + # Use ignore to remove param from processing, all valid param are processed + on_security_fail ignore + + # Use abort to return 400 Bad Request when fails + # on_security_fail abort + + # Use bypass to serve original image without processing + # on_security_fail bypass + + # Explicitely disable rotate capabilities + disallowed_params r - disallowed_params w r ... # These parameters are disallowed in the image processing request. You can also use allowed_params to restrict parameters further. - # Note: 'allowed_params' and 'disallowed_params' cannot be used together. You must choose one or the other. + # As an alternative use this to only accept width and height processing + # allowed_params w h + + constraints { + h range 60 480 + + w { + values 60 130 240 480 637 + + # Shortcut range 60 637 + range { + from 60 + to 637 + } + } + + } } - } + } } ``` @@ -189,6 +225,7 @@ localhost { * `allowed_params`: Specify which query parameters are allowed. As an alternative to `disallowed_params`. * **Important**: You cannot use both allowed_params and disallowed_params in the same configuration. + * `constraints`: You san specify constraints for each parameter (see example) ## Planned Features diff --git a/constraint_range.go b/constraint_range.go new file mode 100644 index 0000000..01b74b8 --- /dev/null +++ b/constraint_range.go @@ -0,0 +1,107 @@ +package CADDY_FILE_SERVER + +import ( + "fmt" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "slices" + "strconv" +) + +func init() { + RegisterConstraintType(func() Constraint { + return new(RangeConstraint) + }) +} + +type RangeConstraint struct { + From int `json:"from,omitempty"` + To int `json:"to,omitempty"` +} + +func (r *RangeConstraint) ID() string { + return "range" +} + +func (r *RangeConstraint) Validate(param string) error { + if !slices.Contains([]string{"w", "h", "q", "ah", "aw", "t", "l", "r", "b"}, param) { + return fmt.Errorf("range constraint cannot be applied on param: '%s'", param) + } + if r.From < 0 { + return fmt.Errorf("range constraint must have minimum value less than 0") + } + if r.From >= r.To { + return fmt.Errorf("range constraint must have minimum value less than max") + } + return nil +} + +func (r *RangeConstraint) ValidateParam(param string, value string) error { + intValue, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid integer value for %s: %s", param, value) + } + + if intValue < r.From || intValue > r.To { + return fmt.Errorf("%s must be in range %d to %d", param, r.From, r.To) + } + + return nil +} + +func (r *RangeConstraint) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + var nested bool + + // Try to load nested block if present + for nesting := d.Nesting(); d.NextBlock(nesting); { + nested = true + param := d.Val() + + switch param { + case "from": + if !d.NextArg() { + return d.Err("missing value for from") + } + var err error + r.From, err = strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid from value for range: %v", err) + } + case "to": + if !d.NextArg() { + return d.Err("missing value for to") + } + var err error + r.To, err = strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid to value for range: %v", err) + } + default: + return d.Errf("unexpected parameter '%s' in range constraint", param) + } + } + + // If not a nested block, process inline arguments + if !nested { + if !d.NextArg() { + return d.Err("missing from value for range constraint") + } + var err error + r.From, err = strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid from value for range: %v", err) + } + + if !d.NextArg() { + return d.Err("missing to value for range constraint") + } + r.To, err = strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid to value for range: %v", err) + } + + if d.NextArg() { + return d.ArgErr() + } + } + return nil +} diff --git a/constraint_values.go b/constraint_values.go new file mode 100644 index 0000000..6c33c4a --- /dev/null +++ b/constraint_values.go @@ -0,0 +1,59 @@ +package CADDY_FILE_SERVER + +import ( + "errors" + "fmt" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "slices" + "strconv" +) + +func init() { + RegisterConstraintType(func() Constraint { + return new(ValuesConstraint) + }) +} + +type ValuesConstraint struct { + Values []int `json:"values"` +} + +func (r *ValuesConstraint) ID() string { + return "values" +} + +func (r *ValuesConstraint) Validate(param string) error { + if !slices.Contains([]string{"w", "h", "q", "ah", "aw", "t", "l", "r", "b"}, param) { + return fmt.Errorf("values constraint cannot be applied on param: '%s'", param) + } + if len(r.Values) == 0 { + return errors.New("you need to provide at least one value for values constraint") + } + return nil +} + +func (r *ValuesConstraint) ValidateParam(param string, value string) error { + intValue, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid integer value for %s: %s", param, value) + } + + if !slices.Contains(r.Values, intValue) { + return fmt.Errorf("parameter %s has an invalid value: %d", param, intValue) + } + + return nil +} + +func (r *ValuesConstraint) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + values := d.RemainingArgs() + r.Values = make([]int, len(values)) + for idx, v := range values { + var err error + if r.Values[idx], err = strconv.Atoi(v); err != nil { + return err + } + } + + return nil +} diff --git a/constraints.go b/constraints.go new file mode 100644 index 0000000..6aa6b60 --- /dev/null +++ b/constraints.go @@ -0,0 +1,177 @@ +package CADDY_FILE_SERVER + +import ( + "encoding/json" + "fmt" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "net/url" +) + +var constraintsRegistry = make(map[string]func() Constraint) + +func RegisterConstraintType(factory func() Constraint) { + typ := factory().ID() + constraintsRegistry[typ] = factory +} + +// Constraints represent constraints as {params:[{type:..., customConfig..}]} +type Constraints map[string][]Constraint + +type Constraint interface { + Validate(param string) error + ValidateParam(param string, value string) error + UnmarshalCaddyfile(d *caddyfile.Dispenser) error + ID() string +} + +// Temporary map to hold serialized constraints with type as a key + +// MarshalJSON serializes Constraints to JSON, adding a `type` field to each entry. +func (cs *Constraints) MarshalJSON() ([]byte, error) { + type jsonConstraintsWrapper map[string][]map[string]Constraint + + // Initialize the map for wrapped constraints + wrappedConstraints := make(jsonConstraintsWrapper, len(*cs)) + + for param, constraintList := range *cs { + var wrappedList []map[string]Constraint + for _, constraint := range constraintList { + wrappedList = append(wrappedList, map[string]Constraint{ + constraint.ID(): constraint, + }) + } + wrappedConstraints[param] = wrappedList + } + + return json.Marshal(wrappedConstraints) +} + +// UnmarshalJSON deserializes JSON into Constraints, dynamically instantiating types using the registry. +func (cs *Constraints) UnmarshalJSON(data []byte) error { + type jsonConstraintsUnwrapper map[string][]map[string]json.RawMessage + + var wrappedConstraints jsonConstraintsUnwrapper + if err := json.Unmarshal(data, &wrappedConstraints); err != nil { + return err + } + + *cs = make(Constraints, len(wrappedConstraints)) + + for param, wrappedList := range wrappedConstraints { + (*cs)[param] = make([]Constraint, len(wrappedList)) + for idx, wrappedConstraint := range wrappedList { + for constraintType, constraintData := range wrappedConstraint { + // Look up the factory function for the given constraint type + factory, found := constraintsRegistry[constraintType] + if !found { + return fmt.Errorf("unknown constraint type: %s for param: %s", constraintType, param) + } + + // Instantiate the correct constraint type + constraint := factory() + + // Unmarshal the constraint data into the instantiated constraint + if err := json.Unmarshal(constraintData, constraint); err != nil { + return fmt.Errorf("error unmarshaling constraint for param %s: %v", param, err) + } + // Add the deserialized constraint to the slice for this parameter + (*cs)[param][idx] = constraint + } + } + } + return nil +} + +func (cs *Constraints) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for nesting := d.Nesting(); d.NextBlock(nesting); { + param := d.Val() + var constraintsForParam []Constraint + + var nested bool + + // Try to detect and process a nested block if any (like `w { range ... , range ... }`) + for nesting := d.Nesting(); d.NextBlock(nesting); { + nested = true + constraintName := d.Val() + + factory, found := constraintsRegistry[constraintName] + if !found { + return d.Errf("unknown constraint type: %s", constraintName) + } + + constraint := factory() + + if err := constraint.UnmarshalCaddyfile(d); err != nil { + return d.Errf("error unmarshaling parameters for %s constraint: %v", constraintName, err) + } + + constraintsForParam = append(constraintsForParam, constraint) + } + + // If no nested block was found, process inline arguments (like `range 10 20`) + if !nested { + if !d.NextArg() { + return d.Errf("missing constraint name for parameter %s", param) + } + + constraintName := d.Val() + + factory, found := constraintsRegistry[constraintName] + if !found { + return d.Errf("unknown constraint type: %s", constraintName) + } + + constraint := factory() + + if err := constraint.UnmarshalCaddyfile(d); err != nil { + return d.Errf("error unmarshaling parameters for %s constraint: %v", constraintName, err) + } + + if d.NextArg() { + return d.ArgErr() + } + + constraintsForParam = append(constraintsForParam, constraint) + + } + + (*cs)[param] = constraintsForParam + } + return nil +} + +func (cs *Constraints) Validate() error { + for param, constraints := range *cs { + for _, constraint := range constraints { + if err := constraint.Validate(param); err != nil { + return err + } + } + } + return nil +} + +func (cs *Constraints) ProcessRequestForm(form *url.Values, onSecurityFail OnSecurityFail) error { + for param, constraints := range *cs { + if !form.Has(param) { + continue + } + + for _, constraint := range constraints { + if err := constraint.ValidateParam(param, form.Get(param)); err != nil { + if onSecurityFail == OnSecurityFailIgnore { + form.Del(param) + } else if onSecurityFail == OnSecurityFailBypass { + return BypassRequestError + } else if onSecurityFail == OnSecurityFailAbort { + return &AbortRequestError{ + err.Error(), + } + } + + return err + } + } + } + return nil +} diff --git a/processing.go b/processing.go index 98acd66..45df1d8 100644 --- a/processing.go +++ b/processing.go @@ -64,7 +64,7 @@ func getOptions(form *url.Values) (bimg.Options, error) { "r": &options.Rotate, // bimg.Angle "b": &options.GaussianBlur.Sigma, // int "bg": &options.Background, // bimg.Color - "fm": &options.Type, // bimg.Type + "fm": &options.Type, // bimg.ID } for param, _ := range *form { diff --git a/security.go b/security.go index 590686b..74535a6 100644 --- a/security.go +++ b/security.go @@ -26,13 +26,7 @@ type SecurityOptions struct { OnSecurityFail OnSecurityFail `json:"on_security_fail,omitempty"` AllowedParams *[]string `json:"allowed_params,omitempty"` DisallowedParams *[]string `json:"disallowed_params,omitempty"` -} - -func MakeSecurityOptions() *SecurityOptions { - return &SecurityOptions{ - OnSecurityFail: OnSecurityFailIgnore, // DEFAULT - AllowedParams: &[]string{"w"}, - } + Constraints *Constraints `json:"constraints,omitempty"` } // ProcessRequestForm @@ -74,13 +68,18 @@ func (s *SecurityOptions) ProcessRequestForm(form *url.Values) error { } } + if s.Constraints != nil { + if err := s.Constraints.ProcessRequestForm(form, s.OnSecurityFail); err != nil { + return err + } + } + return nil } // Provision Set default values if not defined func (s *SecurityOptions) Provision(ctx caddy.Context) error { s.OnSecurityFail = cmp.Or(s.OnSecurityFail, OnSecurityFailIgnore) - return nil } @@ -90,7 +89,14 @@ func (s *SecurityOptions) Validate() error { case OnSecurityFailIgnore, OnSecurityFailAbort, OnSecurityFailBypass: // Valid values default: - return fmt.Errorf("invalid value for on_security_fail: '%s' (expected 'ignore', 'abort', or 'bypass')", s.OnSecurityFail) + return fmt.Errorf("invalid value for 'on_security_fail': '%s' (expected 'ignore', 'abort', or 'bypass')", s.OnSecurityFail) + } + + // Validate constraints if exists + if s.Constraints != nil { + if err := s.Constraints.Validate(); err != nil { + return err + } } // Check that AllowedParams and DisallowedParams are not both specified @@ -98,9 +104,11 @@ func (s *SecurityOptions) Validate() error { return fmt.Errorf("'allowed_params' and 'disallowed_params' cannot be specified together") } - // Ensure that at least one of AllowedParams or DisallowedParams is specified - if (s.AllowedParams == nil || len(*s.AllowedParams) == 0) && (s.DisallowedParams == nil || len(*s.DisallowedParams) == 0) { - return fmt.Errorf("either 'allowed_params' or 'disallowed_params' must be specified") + // Ensure that at least one of AllowedParams or DisallowedParams or 'Constraints' is specified + if (s.AllowedParams == nil || len(*s.AllowedParams) == 0) && + (s.DisallowedParams == nil || len(*s.DisallowedParams) == 0) && + (s.Constraints == nil || len(*s.Constraints) == 0) { + return fmt.Errorf("either 'allowed_params', 'disallowed_params', or 'constraints' must be specified") } // Validate that all elements in AllowedParams are in availableParams @@ -133,7 +141,7 @@ func (s *SecurityOptions) Validate() error { } func (s *SecurityOptions) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { - for d.NextBlock(1) { + for nesting := d.Nesting(); d.NextBlock(nesting); { switch d.Val() { case "on_security_fail": // Check if argument provided @@ -161,6 +169,13 @@ func (s *SecurityOptions) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } s.DisallowedParams = &disallowedParams break + case "constraints": + // If it's a nested block, process it + s.Constraints = &Constraints{} + if err := s.Constraints.UnmarshalCaddyfile(d); err != nil { + return err + } + break default: return d.Errf("unexpected directive '%s' in security block", d.Val()) }