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
20 changes: 10 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
go-version-file: "go.mod"
cache: true
- name: Run go vet
run: go vet ./...
- uses: golangci/golangci-lint-action@v6
- uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0
with:
version: latest
args: --timeout=5m
Expand All @@ -36,8 +36,8 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
go-version-file: "go.mod"
cache: true
Expand All @@ -46,7 +46,7 @@ jobs:
- name: Print coverage summary
run: go tool cover -func=coverage.out | tail -1
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
if: always()
with:
name: coverage
Expand All @@ -62,8 +62,8 @@ jobs:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
go-version-file: "go.mod"
cache: true
Expand All @@ -79,9 +79,9 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
- uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1
with:
configFile: .commitlintrc.json
8 changes: 4 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,20 @@ jobs:
name: Analyze (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-go@v5
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
# Single source of truth: go.mod's go directive.
go-version-file: "go.mod"
cache: true

- uses: github/codeql-action/init@v3
- uses: github/codeql-action/init@42c378ffbb438be29e3e1848d520fb0a8ee1c271 # v3.38.0
with:
languages: go
# Extend with security-and-quality for a broader ruleset
queries: security-extended

- uses: github/codeql-action/analyze@v3
- uses: github/codeql-action/analyze@42c378ffbb438be29e3e1848d520fb0a8ee1c271 # v3.38.0
with:
category: "/language:go"
12 changes: 6 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ jobs:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

- uses: actions/setup-go@v5
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
go-version-file: "go.mod"
cache: true
Expand All @@ -30,18 +30,18 @@ jobs:
# provides a builder capable of producing a multi-platform
# manifest in a single pass.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3

- uses: docker/login-action@v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: goreleaser/goreleaser-action@v6
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: latest
args: release --clean
Expand Down
6 changes: 0 additions & 6 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
# The Makefile runs this via `golangci-lint run ./...`, as the `lint`
# target. `make check` chains fmt → vet → lint → test.

version: "2"

run:
# The default 1m timeout is tight for a cold CI run that includes
# race-instrumented tests. 5m gives headroom without being effectively
Expand All @@ -24,14 +22,10 @@ run:
tests: true

linters:
default: standard
enable:
- bodyclose
- gosec
- misspell

formatters:
enable:
- gofmt
- goimports
Comment on lines 29 to 30

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Requires Go 1.27.0 or later.
```bash
docker run --rm -p 8080:8080 \
-e GITHUB_WEBHOOK_SECRET=your_secret \
ghcr.io/0xprogress/webhookd:latest github
ghcr.io/0xprogress/webhookd:latest github --host 0.0.0.0
```

### Binary
Expand Down Expand Up @@ -368,7 +368,7 @@ The most impactful contribution is **adding a provider**. It is a well-scoped,
well-documented process:

1. Find or open the provider issue.
2. Read the [provider guide](docs/contributing/provider-guide.md).
2. Read [CONTRIBUTING.md](CONTRIBUTING.md).
3. Implement against the `Provider` interface under `providers/<name>/`.
4. Add `cmd/<name>.go`, write tests, write the doc file, run `make check`.
5. Open a PR.
Expand Down
1 change: 0 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ signature verification. The following are always in scope:

## Out of Scope

- Vulnerabilities in community providers not maintained in this repo
- Issues requiring the attacker to already have the webhook secret
Binary file added bin/webhookd
Binary file not shown.
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,4 @@ func runList(cmd *cobra.Command) error {
func runVersion(cmd *cobra.Command) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "webhookd %s\n", buildVersion)
return err
}
}
6 changes: 5 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ func Load(cmd *cobra.Command, providerName string) (*Config, error) {
cfg.Port = port

// --host
cfg.Host = stringValue(cmd, "host", EnvPrefix+"HOST", DefaultHost)
host := stringValue(cmd, "host", EnvPrefix+"HOST", DefaultHost)
if strings.TrimSpace(host) == "" {
return nil, errors.New("config: host cannot be empty")
}
cfg.Host = host

// --path
defaultPath := "/" + providerName
Expand Down
2 changes: 1 addition & 1 deletion docs/providers/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ box must be checked before the PR is ready for review.
- [ ] I have commented on the issue to claim it
- [ ] I have read the provider's official webhook documentation
- [ ] I have read [CONTRIBUTING.md §"Adding a Provider"](../../CONTRIBUTING.md#adding-a-provider) end to end
- [ ] I have read [docs/contributing/provider-guide.md](provider-guide.md)
- [ ] I have read [CONTRIBUTING.md §"Adding a Provider"](../../CONTRIBUTING.md#adding-a-provider)
- [ ] I have read [providers/provider.go](../../providers/provider.go) — the interface doc comments

---
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ var version = "dev"

func main() {
os.Exit(cmd.Execute(version))
}
}
3 changes: 3 additions & 0 deletions output/pretty.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ type PrettyWriter struct {

// NewPrettyWriter returns a PrettyWriter that emits to w.
func NewPrettyWriter(w io.Writer) *PrettyWriter {
if w == nil {
w = io.Discard
}
return &PrettyWriter{w: w}
}

Expand Down
3 changes: 3 additions & 0 deletions output/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type Writer struct {

// NewWriter returns a Writer that emits to w.
func NewWriter(w io.Writer) *Writer {
if w == nil {
w = io.Discard
}
return &Writer{w: w}
}

Expand Down
33 changes: 25 additions & 8 deletions server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ type Handler struct {
maxBody int64
}

const (
errMethodNotAllowed = "method not allowed"
errUnsupportedMediaType = "unsupported content type"
errPayloadTooLarge = "request body too large"
errUnknownProvider = "unknown provider"
errSigVerificationFailed = "signature verification failed"
errInternal = "internal error"
)

// NewHandler returns a Handler bound to the named provider.
//
// out receives verified events, one call per accepted request. errOut
Expand All @@ -54,6 +63,9 @@ type Handler struct {
// read by http.MaxBytesReader, so an oversized body is never fully
// buffered.
func NewHandler(providerName string, out EventWriter, errOut io.Writer, maxBody int64) *Handler {
if errOut == nil {
errOut = io.Discard
}
return &Handler{
providerName: providerName,
out: out,
Expand All @@ -67,14 +79,14 @@ func NewHandler(providerName string, out EventWriter, errOut io.Writer, maxBody
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Step 1 — method check.
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
writeError(w, http.StatusMethodNotAllowed, errMethodNotAllowed)
return
}

// Step 2 — content-type check. A charset suffix is permitted, so
// the check is a prefix match rather than an equality check.
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "unsupported content type")
writeError(w, http.StatusUnsupportedMediaType, errUnsupportedMediaType)
return
}

Expand All @@ -87,10 +99,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
writeError(w, http.StatusRequestEntityTooLarge, errPayloadTooLarge)
return
}
writeError(w, http.StatusInternalServerError, "internal error")
writeError(w, http.StatusInternalServerError, errInternal)
return
}

Expand All @@ -99,7 +111,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// not have.
provider, ok := providers.Get(h.providerName)
if !ok {
writeError(w, http.StatusNotFound, "unknown provider")
writeError(w, http.StatusNotFound, errUnknownProvider)
return
}

Expand All @@ -109,7 +121,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// to the event stream on a failed verification.
if err := provider.Verify(r, rawBody); err != nil {
fmt.Fprintf(h.errOut, "webhookd: %s — %s\n", err.Error(), remoteIP(r))
writeError(w, http.StatusUnauthorized, "signature verification failed")
writeError(w, http.StatusUnauthorized, errSigVerificationFailed)
return
}

Expand All @@ -126,7 +138,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// case of a valid signature over a body that is not JSON at all,
// which is a provider-integration error, not an attack.
if !json.Valid(rawBody) {
writeError(w, http.StatusInternalServerError, "internal error")
writeError(w, http.StatusInternalServerError, errInternal)
return
}

Expand All @@ -147,9 +159,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// failure is a diagnostic, not a data-line failure: nothing was
// written to the event stream, and the client learns the request
// was not recorded.
if h.out == nil {
fmt.Fprintf(h.errOut, "webhookd: write event: nil EventWriter\n")
writeError(w, http.StatusInternalServerError, errInternal)
return
}
if err := h.out.Write(event); err != nil {
fmt.Fprintf(h.errOut, "webhookd: write event: %v\n", err)
writeError(w, http.StatusInternalServerError, "internal error")
writeError(w, http.StatusInternalServerError, errInternal)
return
}

Expand Down
4 changes: 3 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ type Server struct {
// requests.
func New(opts Options) *Server {
mux := http.NewServeMux()
mux.Handle(opts.Path, opts.Handler)
if opts.Handler != nil && opts.Path != "" {
mux.Handle(opts.Path, opts.Handler)
}
mux.Handle("/health", newHealthHandler(normalizeVersion(opts.Version)))

return &Server{
Expand Down
40 changes: 39 additions & 1 deletion server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

"github.com/0xProgress/webhookd/output"
"github.com/0xProgress/webhookd/server"

_ "github.com/0xProgress/webhookd/providers/mock"
"github.com/0xProgress/webhookd/server"
)

// syncBuffer is a bytes.Buffer guarded by a mutex.
Expand Down Expand Up @@ -472,3 +473,40 @@ func TestStartupBanner(t *testing.T) {
t.Fatalf("banner =\n %q\nwant:\n %q", got, want)
}
}

func TestHandlerNilWriter(t *testing.T) {
stderr := &syncBuffer{}
// Construct handler with a nil EventWriter to ensure no panics occur when serving a request
h := server.NewHandler("mock", nil, stderr, 2*1024*1024)

rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mock", strings.NewReader(`{"type":"test","id":"evt_1"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Mock-Signature", "valid")

h.ServeHTTP(rec, req)

if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
if !strings.Contains(stderr.String(), "webhookd: write event: nil EventWriter") {
t.Fatalf("stderr did not log nil EventWriter: %q", stderr.String())
}
}

func TestWriterNilOutput(t *testing.T) {
w := output.NewWriter(nil)
evt := &output.Event{
Provider: "mock",
Verified: true,
Payload: []byte("{}"),
}
if err := w.Write(evt); err != nil {
t.Fatalf("Writer.Write with nil w returned error: %v", err)
}

pw := output.NewPrettyWriter(nil)
if err := pw.Write(evt); err != nil {
t.Fatalf("PrettyWriter.Write with nil w returned error: %v", err)
}
}
Loading