Felix Niederwanger
46418bcf3a
Sanitize the environment variables when running webhooks and add ability to add custom environment variables per webhook.
183 lines
5.5 KiB
Go
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
|
|
}
|