weblug/cmd/weblug/hook.go
Felix Niederwanger 46418bcf3a
Add env sanitation
Sanitize the environment variables when running webhooks and add ability
to add custom environment variables per webhook.
2023-05-28 11:44:42 +02:00

183 lines
5.5 KiB
Go

package main
import (
"fmt"
"net"
"os/exec"
"strings"
"sync/atomic"
"syscall"
)
type Hook struct {
Name string `yaml:"name"` // name of the hook
Route string `yaml:"route"` // http route
Command string `yaml:"command"` // Actual command to execute
Background bool `yaml:"background"` // Run in background
Concurrency int `yaml:"concurrency"` // Number of allowed concurrent runs
concurrentRuns int32 // Number of current concurrent runs
UID int `yaml:"uid"` // UID to use when running the command
GID int `yaml:"gid"` // GID to use when running the command
Output bool `yaml:"output"` // Print program output
AllowAddresses []string `yaml:"allowed"` // Addresses that are explicitly allowed
BlockedAddresses []string `yaml:"blocked"` // Addresses that are explicitly blocked
HttpBasicAuth BasicAuth `yaml:"basic_auth"` // Optional requires http basic auth
Env map[string]string `yaml:"env"` // Optional environment variables
}
type BasicAuth struct {
Username string `yaml:"username"` // Optional: Required username for the webhook to be allowed. If empty, any username will be accepted
Password string `yaml:"password"` // If set, the http basic auth is enabled and the request must contain this password for being allowed
}
// Tries to lock a spot. Returns false, if the max. number of concurrent runs has been reached
func (hook *Hook) TryLock() bool {
res := int(atomic.AddInt32(&hook.concurrentRuns, 1))
if res > hook.Concurrency {
atomic.AddInt32(&hook.concurrentRuns, -1)
return false
}
return true
}
func (hook *Hook) Unlock() {
atomic.AddInt32(&hook.concurrentRuns, -1)
}
// Split a command into program arguments, obey quotation mark escapes
func cmdSplit(command string) []string {
null := rune(0)
esc := null // Escape character or \0 if not escaped currently
ret := make([]string, 0)
buf := "" // Current command
for _, char := range command {
if esc != null {
if char == esc {
esc = null
} else {
buf += string(char)
}
} else {
// Check for quotation marks
if char == '\'' || char == '"' {
esc = char
} else if char == ' ' {
ret = append(ret, buf)
buf = ""
} else {
buf += string(char)
}
}
}
// Remaining characters
if buf != "" {
ret = append(ret, buf)
buf = ""
}
return ret
}
// Run executes the given command and return it's return code. It also respects the given concurrency number and will block until resources are free
func (hook *Hook) Run() error {
split := cmdSplit(hook.Command)
args := make([]string, 0)
if len(split) > 1 {
args = split[1:]
}
cmd := exec.Command(split[0], args...)
if hook.UID > 0 || hook.GID > 0 {
cmd.SysProcAttr = &syscall.SysProcAttr{}
cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(hook.UID), Gid: uint32(hook.GID)}
}
cmd.Env = make([]string, 0)
if hook.Env != nil {
// Build environment variable list as expected by cmd.Env
for k, v := range hook.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
if hook.Output {
buf, ret := cmd.Output()
fmt.Println(string(buf))
return ret
}
return cmd.Run()
}
func isAddressInList(addr string, addrList []string) (bool, error) {
ip, _, err := net.ParseCIDR(addr)
if err != nil {
return false, err
}
for _, item := range addrList {
iAddr, iNet, err := net.ParseCIDR(item)
if err != nil {
return false, err
}
if ip.Equal(iAddr) {
return true, nil
}
if iNet.Contains(ip) {
return true, nil
}
}
return false, nil
}
// Extract only the CIDR address from the given address identifier. This removes the port, if present
func cidr(addr string) string {
// Check for IPv6 address
s, e := strings.Index(addr, "["), strings.Index(addr, "]")
if s >= 0 && e > 0 {
return addr[s+1:e] + "/128"
}
// Simply remove the port
i := strings.Index(addr, ":")
if i > 0 {
return addr[:i-1] + "/32"
}
return addr + "/32"
}
// IsAddressAllowed checks if the hook allows the given address. An address is allowed, if it is present in the AllowAddresses list (if non-empty) and if it is not present in the BlockedAddresses list (if non-empty)
func (hook *Hook) IsAddressAllowed(addr string) (bool, error) {
if addr == "" {
// If we cannot determine the source address, but there are element in either the Allow or the Block list, the only safe thing we can do is to reject
if hook.AllowAddresses != nil && len(hook.AllowAddresses) > 0 {
return false, fmt.Errorf("no source address")
}
if hook.BlockedAddresses != nil && len(hook.BlockedAddresses) > 0 {
return false, fmt.Errorf("no source address")
}
}
addr = cidr(addr)
if hook.AllowAddresses != nil && len(hook.AllowAddresses) > 0 {
// If AllowAddresses is defined and not empty, the given addr must be in the AllowAddresses list
found, err := isAddressInList(addr, hook.AllowAddresses)
if err != nil {
return false, err
}
// If not present in the list, block the request. Otherwise we still need to pass the BlockedAddresses check
if !found {
return false, err
}
}
if hook.BlockedAddresses != nil && len(hook.BlockedAddresses) > 0 {
// If BlockedAddresses is defined and not empty, the given addr must not be in the BlockedAddresses list
found, err := isAddressInList(addr, hook.BlockedAddresses)
if err != nil {
return false, err
}
if found {
return false, err
}
}
return true, nil
}