Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
490 changes: 158 additions & 332 deletions README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions etc/default/webhookd.env
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
# Example: `/etc/webhookd/pubkey.pem`
#WHD_TRUSTSTORE_FILE=

# Allowed upstream HTTP headers, default is "*"
# Comma separated list of HTTP headers to pass to the hook script.
# Example: `x-webauth-user,content-type,user-agent`
#WHD_ALLOWED_UPSTREAM_HEADERS="*"

# Activate TLS, default is false
#WHD_TLS_ENABLED=false
# TLS key file, default is "./server.key"
Expand Down
17 changes: 11 additions & 6 deletions pkg/api/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ import (

"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/helper"
"github.com/ncarlier/webhookd/pkg/helper/header"
"github.com/ncarlier/webhookd/pkg/hook"
"github.com/ncarlier/webhookd/pkg/worker"
)

var (
defaultTimeout int
defaultExt string
defaultMode string
scriptDir string
outputDir string
defaultTimeout int
defaultExt string
defaultMode string
scriptDir string
outputDir string
allowedUpstreamHeaders []string
)

const (
Expand All @@ -49,6 +51,7 @@ func index(conf *config.Config) http.Handler {
scriptDir = conf.Hook.ScriptsDir
outputDir = conf.Hook.LogDir
defaultMode = conf.Hook.DefaultMode
allowedUpstreamHeaders = conf.AllowedUpstreamHeaders
return http.HandlerFunc(webhookHandler)
}

Expand Down Expand Up @@ -129,7 +132,9 @@ func triggerWebhook(w http.ResponseWriter, r *http.Request) {
}

params := HTTPParamsToShellVars(r.Form)
params = append(params, HTTPParamsToShellVars(r.Header)...)

filteredHeaders := header.FilterHeaders(r.Header, allowedUpstreamHeaders)
params = append(params, HTTPParamsToShellVars(filteredHeaders)...)

// Create hook job
timeout := atoiFallback(r.Header.Get("X-Hook-Timeout"), defaultTimeout)
Expand Down
6 changes: 5 additions & 1 deletion pkg/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/ncarlier/webhookd/pkg/auth"
"github.com/ncarlier/webhookd/pkg/config"
"github.com/ncarlier/webhookd/pkg/helper"
"github.com/ncarlier/webhookd/pkg/middleware"
"github.com/ncarlier/webhookd/pkg/truststore"
)
Expand Down Expand Up @@ -34,10 +35,13 @@ func buildMiddlewares(conf *config.Config) middleware.Middlewares {
// Load authenticator...
authenticator, err := auth.NewHtpasswdFromFile(conf.PasswdFile)
if err != nil {
slog.Debug("unable to load htpasswd file", "filename", conf.PasswdFile, "err", err)
slog.Warn("unable to load htpasswd file", "filename", conf.PasswdFile, "err", err)
}

if authenticator != nil {
middlewares = middlewares.UseAfter(middleware.AuthN(authenticator))
} else if helper.ContainsFold(conf.AllowedUpstreamHeaders, middleware.UpstreamAuthHeader) {
slog.Info("using upstream authentication", "header", middleware.UpstreamAuthHeader)
}
return middlewares
}
Expand Down
4 changes: 4 additions & 0 deletions pkg/auth/htpasswd-file.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type HtpasswdFile struct {

// NewHtpasswdFromFile reads the users and passwords from a htpasswd file and returns them.
func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) {
if path == "" {
return nil, nil
}

r, err := os.Open(path)
if err != nil {
return nil, err
Expand Down
19 changes: 10 additions & 9 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import (

// Config store root configuration
type Config struct {
ListenAddr string `flag:"listen-addr" desc:"HTTP listen address" default:":8080"`
PasswdFile string `flag:"passwd-file" desc:"Password file for basic HTTP authentication" default:".htpasswd"`
TruststoreFile string `flag:"truststore-file" desc:"Truststore used by HTTP signature verifier (.pem or .p12)"`
Hook HookConfig `flag:"hook"`
Log LogConfig `flag:"log"`
Notification NotificationConfig `flag:"notification"`
Static StaticConfig `flag:"static"`
TLS TLSConfig `flag:"tls"`
OldConfig `flag:""`
ListenAddr string `flag:"listen-addr" desc:"HTTP listen address" default:":8080"`
PasswdFile string `flag:"passwd-file" desc:"Password file for basic HTTP authentication" default:".htpasswd"`
TruststoreFile string `flag:"truststore-file" desc:"Truststore used by HTTP signature verifier (.pem or .p12)"`
Hook HookConfig `flag:"hook"`
Log LogConfig `flag:"log"`
Notification NotificationConfig `flag:"notification"`
Static StaticConfig `flag:"static"`
TLS TLSConfig `flag:"tls"`
AllowedUpstreamHeaders []string `flag:"allowed-upstream-headers" desc:"Allowed HTTP upstream headers" default:"accept,content-type,content-length,user-agent,x-forwarded-for"`
OldConfig `flag:""`
}

// HookConfig store Hook execution configuration
Expand Down
27 changes: 27 additions & 0 deletions pkg/helper/header/misc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package header

import (
"net/http"
"strings"
)

// FilterHeaders filters the given HTTP headers based on a list of allowed header names.
// If the allowed list contains "*", it returns all headers unmodified.
func FilterHeaders(headers http.Header, allowedHeaders []string) http.Header {
for _, h := range allowedHeaders {
if h == "*" {
return headers
}
}

filteredHeaders := make(http.Header)
for k, v := range headers {
for _, allowed := range allowedHeaders {
if strings.EqualFold(k, allowed) {
filteredHeaders[k] = v
break
}
}
}
return filteredHeaders
}
17 changes: 17 additions & 0 deletions pkg/helper/slice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package helper

import "strings"

// ContainsFold checks if array contains string (case-insensitive)
func ContainsFold(arr []string, str string) bool {
if len(arr) == 0 {
return false
}

for _, s := range arr {
if strings.EqualFold(s, str) {
return true
}
}
return false
}
70 changes: 70 additions & 0 deletions pkg/helper/test/misc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package test

import (
"net/http"
"reflect"
"testing"

"github.com/ncarlier/webhookd/pkg/helper/header"
)

func TestFilterHeaders(t *testing.T) {
testCases := []struct {
name string
headers http.Header
allowedHeaders []string
expected http.Header
}{
{
name: "allow all headers",
headers: http.Header{
"X-Foo": []string{"bar"},
"Y-Bar": []string{"baz"},
},
allowedHeaders: []string{"*"},
expected: http.Header{
"X-Foo": []string{"bar"},
"Y-Bar": []string{"baz"},
},
},
{
name: "filter specific header",
headers: http.Header{
"X-Foo": []string{"bar"},
"Y-Bar": []string{"baz"},
},
allowedHeaders: []string{"X-Foo"},
expected: http.Header{
"X-Foo": []string{"bar"},
},
},
{
name: "case insensitive filter",
headers: http.Header{
"X-Foo-Bar": []string{"baz"},
"Y-Bar": []string{"foo"},
},
allowedHeaders: []string{"x-foo-bar"},
expected: http.Header{
"X-Foo-Bar": []string{"baz"},
},
},
{
name: "no allowed headers",
headers: http.Header{
"X-Foo": []string{"bar"},
},
allowedHeaders: []string{},
expected: http.Header{},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := header.FilterHeaders(tc.headers, tc.allowedHeaders)
if !reflect.DeepEqual(actual, tc.expected) {
t.Errorf("expected %v, got %v", tc.expected, actual)
}
})
}
}
6 changes: 3 additions & 3 deletions pkg/middleware/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import (
"github.com/ncarlier/webhookd/pkg/auth"
)

const xWebAuthUser = "X-WebAuth-User"
const UpstreamAuthHeader = "X-WebAuth-User"

// AuthN is a middleware to checks HTTP request credentials
func AuthN(authenticator auth.Authenticator) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Del(xWebAuthUser)
r.Header.Del(UpstreamAuthHeader)
if ok, username := authenticator.Validate(r); ok {
r.Header.Set(xWebAuthUser, username)
r.Header.Set(UpstreamAuthHeader, username)
next.ServeHTTP(w, r)
return
}
Expand Down
Loading