Skip to content

Commit 5f72894

Browse files
authored
feat(webhooks): simplify host whitelist with permissive default (mcuadros#410)
## Summary Simplifies webhook security to follow the same trust model as local command execution: **if you control the configuration, you control the behavior**. - `webhook-allowed-hosts` defaults to `*` (allow all hosts) - Setting specific hosts enables whitelist mode - Removed complex SSRF blocking (inconsistent with local command trust model) Closes mcuadros#407 ## Security Model Since Ofelia already trusts users to: - Run arbitrary commands via `job-local` - Execute commands in containers via `job-exec` It applies the same trust level to webhook destinations. The user controls the config; the user controls what happens. ## Configuration | Setting | Behavior | |---------|----------| | `webhook-allowed-hosts = *` (default) | All hosts allowed | | `webhook-allowed-hosts = hooks.slack.com, ntfy.internal` | Whitelist mode | ### Default (self-hosted environments) No configuration needed - all hosts work out of the box: ```ini # No config required - webhook-allowed-hosts defaults to "*" ``` ### Whitelist mode (cloud/multi-tenant deployments) ```ini [global] webhook-allowed-hosts = hooks.slack.com, discord.com, ntfy.internal, 192.168.1.20 ``` Supports wildcards: ```ini [global] webhook-allowed-hosts = *.slack.com, *.internal.example.com ``` ## Test Plan - [x] Unit tests for default `*` configuration (allow all) - [x] Unit tests for whitelist mode with specific hosts - [x] Unit tests for wildcard matching - [x] Documentation updated (webhooks.md, SECURITY.md) - [x] All existing tests pass
2 parents aea158a + c85d3e8 commit 5f72894

File tree

7 files changed

+499
-467
lines changed

7 files changed

+499
-467
lines changed

cli/config_webhook.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ func parseGlobalWebhookConfig(section *ini.Section, c *Config) {
169169
if key, err := section.GetKey("preset-cache-dir"); err == nil {
170170
c.WebhookConfigs.Global.PresetCacheDir = key.String()
171171
}
172+
173+
// Host whitelist: "*" = allow all (default), specific list = whitelist mode
174+
if key, err := section.GetKey("webhook-allowed-hosts"); err == nil {
175+
c.WebhookConfigs.Global.AllowedHosts = key.String()
176+
}
172177
}
173178

174179
// JobWebhookConfig holds per-job webhook configuration

docs/SECURITY.md

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -315,31 +315,35 @@ pull = always
315315
```
316316
317317
### A10:2021 - Server-Side Request Forgery (SSRF)
318-
**Protection**: URL validation and network restrictions
318+
**Protection**: Trust-the-config model with optional host whitelist
319319
320-
- ✅ **SSRF Prevention** ([config/sanitizer.go](../config/sanitizer.go)):
321-
```go
322-
// Blocked targets
323-
localhost, 127.0.0.1, 0.0.0.0
324-
192.168.x.x, 10.x.x.x, 172.16-31.x.x
325-
*.local
320+
Ofelia follows a **trust-the-config** security model for webhooks: since users can already run arbitrary commands via local/exec jobs, the same trust level applies to webhook destinations. **All hosts are allowed by default.**
326321
327-
// Only http:// and https:// allowed
328-
err := sanitizer.ValidateURL("https://api.example.com/webhook")
329-
```
322+
- ✅ **Trust Model** ([middlewares/webhook_security.go](../middlewares/webhook_security.go)):
323+
- If you control the configuration, you control the behavior
324+
- Same trust level as local command execution
325+
- Default: `webhook-allowed-hosts = *` (allow all hosts)
330326
331-
-**Network Isolation**:
332-
- Docker network segmentation
333-
- Container-to-container restrictions
334-
- Egress filtering (optional)
327+
- ✅ **URL Validation**:
328+
- Only `http://` and `https://` schemes allowed
329+
- URL must have a valid hostname
335330
336-
**Configuration**:
331+
- ✅ **Optional Whitelist Mode** (for multi-tenant/cloud deployments):
332+
- Set specific hosts to enable whitelist mode
333+
- Supports wildcards: `*.example.com`
334+
335+
**Default** (self-hosted/trusted environments):
337336
```ini
338-
# Restrict container networking
339-
network = isolated_network
337+
[global]
338+
# All hosts allowed by default (no config needed)
339+
# webhook-allowed-hosts = *
340+
```
340341

341-
# Disable direct internet access
342-
dns = 10.0.0.1 # Internal DNS only
342+
**Whitelist Mode** (for cloud/multi-tenant deployments):
343+
```ini
344+
[global]
345+
# Only allow specific hosts
346+
webhook-allowed-hosts = hooks.slack.com, discord.com, ntfy.internal, 192.168.1.20
343347
```
344348

345349
## Authentication & Authorization
@@ -482,7 +486,7 @@ if validator.HasErrors() {
482486
| Shell Injection | Command validation | `; & \| < >`, `&&`, `\|\|`, `$()`, `` ` `` |
483487
| Path Traversal | Path sanitization | `../`, `..\\`, `%2e%2e`, `~` |
484488
| XSS | HTML escaping | `<`, `>`, `&`, `"`, `'` |
485-
| SSRF | URL validation | `localhost`, `127.0.0.1`, private IPs |
489+
| SSRF | URL validation + optional whitelist | Scheme validation, optional host whitelist |
486490
| LDAP Injection | Character filtering | `( ) * \| & !` |
487491

488492
**Usage Examples**:
@@ -505,7 +509,7 @@ err := sanitizer.ValidateDockerImage("nginx:1.21-alpine")
505509
// Environment variable validation
506510
err := sanitizer.ValidateEnvironmentVar("MY_VAR", "value123")
507511

508-
// URL validation (SSRF prevention)
512+
// URL validation (scheme and format)
509513
err := sanitizer.ValidateURL("https://api.example.com/webhook")
510514
```
511515

docs/webhooks.md

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -365,22 +365,58 @@ body: |
365365

366366
## Security
367367

368-
### SSRF Protection
368+
### Security Model
369369

370-
Ofelia includes Server-Side Request Forgery (SSRF) protection that blocks webhooks to:
370+
Ofelia's webhook security follows the same trust model as local command execution: **if you control the configuration, you control the behavior**. Since Ofelia already trusts users to run arbitrary commands on the host or in containers, it applies the same trust level to webhook destinations.
371371

372-
- Localhost and loopback addresses (`127.0.0.1`, `::1`, `localhost`)
373-
- Private networks (`10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`)
374-
- Link-local addresses (`169.254.x.x`)
375-
- Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`)
376-
- Internal hostnames (`.local`, `.internal`, `.corp`)
372+
### Host Whitelist
373+
374+
The `webhook-allowed-hosts` setting controls which hosts webhooks can target:
375+
376+
| Value | Behavior |
377+
|-------|----------|
378+
| `*` (default) | Allow all hosts - webhooks can target any URL |
379+
| Specific hosts | Whitelist mode - only listed hosts are allowed |
380+
381+
#### Default: Allow All Hosts
382+
383+
```ini
384+
[global]
385+
; Default behavior - all hosts allowed (no config needed)
386+
webhook-allowed-hosts = *
387+
```
388+
389+
Webhooks can target any host including `192.168.x.x`, `10.x.x.x`, `localhost`, etc.
390+
391+
#### Whitelist Mode
392+
393+
For multi-tenant or cloud deployments, restrict webhooks to specific hosts:
394+
395+
```ini
396+
[global]
397+
webhook-allowed-hosts = hooks.slack.com, discord.com, ntfy.sh, 192.168.1.20
398+
```
399+
400+
Only the listed hosts can receive webhooks. Supports domain wildcards:
401+
402+
```ini
403+
[global]
404+
webhook-allowed-hosts = *.slack.com, *.internal.example.com
405+
```
406+
407+
#### Configuration Reference
408+
409+
| Option | Type | Default | Description |
410+
|--------|------|---------|-------------|
411+
| `webhook-allowed-hosts` | string | `*` | Host whitelist. `*` = allow all, specific list = whitelist mode. Supports domain wildcards (`*.example.com`) |
377412

378413
### Best Practices
379414

380415
1. **Keep secrets secure**: Use environment variables or secret management for webhook credentials
381416
2. **Use HTTPS**: Always use HTTPS URLs for production webhooks
382417
3. **Limit remote presets**: Keep `webhook-allow-remote-presets = false` unless necessary
383418
4. **Audit presets**: Review remote preset sources before enabling them
419+
5. **Use whitelist in cloud**: Set `webhook-allowed-hosts` to specific hosts for multi-tenant deployments
384420

385421
## Migration from Slack Middleware
386422

@@ -441,10 +477,20 @@ retry-count = 5
441477
retry-delay = 10s
442478
```
443479

444-
### SSRF blocked
480+
### Host not allowed (whitelist mode)
481+
482+
If you've configured `webhook-allowed-hosts` with specific hosts and get "host not in allowed hosts list":
483+
484+
1. **Add the host to the whitelist**:
485+
```ini
486+
[global]
487+
webhook-allowed-hosts = hooks.slack.com, 192.168.1.20, ntfy.local
488+
```
445489

446-
If you need to send webhooks to internal services, consider:
490+
2. **Allow all hosts** (default behavior):
491+
```ini
492+
[global]
493+
webhook-allowed-hosts = *
494+
```
447495

448-
1. Using a webhook relay service
449-
2. Running Ofelia with custom SSRF allowlists (requires code modification)
450-
3. Setting up a proxy that forwards to internal services
496+
See the [Host Whitelist](#host-whitelist) section for details.

middlewares/webhook.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ func NewWebhookManager(globalConfig *WebhookGlobalConfig) *WebhookManager {
296296
globalConfig = DefaultWebhookGlobalConfig()
297297
}
298298

299+
// Configure global security settings based on the webhook global config
300+
// This affects URL validation and DNS rebinding protection
301+
securityConfig := SecurityConfigFromGlobal(globalConfig)
302+
SetGlobalSecurityConfig(securityConfig)
303+
299304
return &WebhookManager{
300305
webhooks: make(map[string]*WebhookConfig),
301306
presetLoader: NewPresetLoader(globalConfig),

middlewares/webhook_config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ type WebhookGlobalConfig struct {
7575

7676
// PresetCacheDir is the directory for caching remote presets
7777
PresetCacheDir string `gcfg:"preset-cache-dir" mapstructure:"preset-cache-dir"`
78+
79+
// AllowedHosts controls which hosts webhooks can target.
80+
// Default: "*" (allow all hosts) - consistent with local command execution trust model
81+
// Set to specific hosts for whitelist mode: "hooks.slack.com, ntfy.internal, 192.168.1.20"
82+
// Supports wildcards: "*.example.com"
83+
AllowedHosts string `gcfg:"allowed-hosts" mapstructure:"allowed-hosts"`
7884
}
7985

8086
// WebhookData is the data structure passed to webhook templates
@@ -141,6 +147,7 @@ func DefaultWebhookGlobalConfig() *WebhookGlobalConfig {
141147
TrustedPresetSources: "",
142148
PresetCacheTTL: 24 * time.Hour,
143149
PresetCacheDir: cacheDir,
150+
AllowedHosts: "*", // Default: allow all hosts (consistent with local command trust model)
144151
}
145152
}
146153

0 commit comments

Comments
 (0)