feat: add webhook auth guard and IRC password/username support

Add WEBHOOK_SECRET env var for authenticating incoming Owncast webhooks
via a ?secret= query parameter. Requests with a missing or incorrect
secret are rejected with 401. If unset, all requests are accepted
(with a startup warning).

Also includes previously uncommitted work:
- IRC server password support (IRC_PASSWORD env var, PASS command)
- IRC username/ident field in config
- IRC_PASSWORD and SELinux volume flag in docker-compose.yml

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-13 00:53:59 -04:00
parent 1af9bd1def
commit 78fec2946c
11 changed files with 212 additions and 7 deletions

1
Cargo.lock generated
View File

@@ -1127,6 +1127,7 @@ dependencies = [
"tokio-test",
"tokio-tungstenite",
"toml 0.8.23",
"tower",
"tracing",
"tracing-subscriber",
]

View File

@@ -29,3 +29,4 @@ futures-util = "0.3"
[dev-dependencies]
tokio-test = "0.4"
tower = { version = "0.5", features = ["util"] }

View File

@@ -29,6 +29,7 @@ Or create a `.env` file (git-ignored):
```
OWNCAST_ACCESS_TOKEN=your-token-here
WEBHOOK_SECRET=some-random-secret
```
**4. Configure the Owncast webhook**
@@ -36,9 +37,11 @@ OWNCAST_ACCESS_TOKEN=your-token-here
In your Owncast admin, go to **Integrations > Webhooks** and add a webhook pointing to:
```
http://<bridge-host>:9078/webhook
http://<bridge-host>:9078/webhook?secret=some-random-secret
```
If `WEBHOOK_SECRET` is set, the bridge rejects any request that doesn't include a matching `?secret=` query parameter. If unset, all requests are accepted (a warning is logged at startup).
Select the events: **Chat Message**, **Stream Started**, **Stream Stopped**.
**5. Run it**
@@ -82,7 +85,13 @@ See [`config.example.toml`](config.example.toml) for all options. The only requi
| `bridge` | `owncast_prefix` | `[OC]` | Prefix for Owncast messages in IRC |
| `control` | `socket_path` | `/tmp/owncast-irc-bridge.sock` | Unix socket for `bridge-ctl` |
The access token is always read from the `OWNCAST_ACCESS_TOKEN` environment variable (not the config file).
Secrets are always read from environment variables (not the config file):
| Variable | Required | Description |
|----------|----------|-------------|
| `OWNCAST_ACCESS_TOKEN` | Yes | Owncast integration API token |
| `IRC_PASSWORD` | No | IRC server password (PASS command) |
| `WEBHOOK_SECRET` | No | Shared secret for webhook authentication |
## Runtime Control

View File

@@ -0,0 +1,17 @@
# IRC Server Password Support
## Task
Add support for connecting to password-protected IRC servers by sending the PASS command during connection, and allow setting a custom IRC username.
## Changes
- **src/config.rs**: Added `username: Option<String>` field to `IrcConfig`. Added `BridgeConfig::irc_server_password()` method that reads `IRC_PASSWORD` from the environment (returns `Option<String>`). Updated `default_for_test()` and tests for the new field and method.
- **src/irc_task.rs**: Set `password` and `username` on the `irc` crate's `Config` when building the IRC connection, sourcing the password from `BridgeConfig::irc_server_password()` and username from `IrcConfig.username`.
- **config.toml**: Removed stale `server_password` field, added comment about `IRC_PASSWORD` env var.
- **config.example.toml**: Added commented-out `username` field and `IRC_PASSWORD` env var documentation.
## Usage
Set the `IRC_PASSWORD` environment variable before running the bridge to authenticate with the IRC server. Optionally set `username` in `[irc]` config for a custom ident.
## Follow-up
- None identified.

View File

@@ -0,0 +1,40 @@
# Webhook Authentication Guard
**Date:** 2026-03-13
## Task
Add a shared secret (`WEBHOOK_SECRET`) to the webhook endpoint so only requests with a matching `?secret=` query parameter are accepted. This prevents unauthorized parties from injecting events into the bridge.
## Changes Made
### `src/config.rs`
- Added `webhook_secret()` static method to `BridgeConfig` — reads `WEBHOOK_SECRET` env var, returns `Option<String>`.
### `src/webhook.rs`
- Added `WebhookQuery` struct for axum query parameter extraction.
- Added `secret: Option<String>` field to `WebhookState`.
- Updated `handle_webhook` to validate the secret before processing: returns 401 if configured secret doesn't match.
- Updated `run_webhook_server` signature to accept `secret: Option<String>`; logs a warning at startup if unset.
- Added 4 integration tests using `tower::ServiceExt::oneshot`: correct secret (200), wrong secret (401), missing secret (401), no secret configured (200).
### `src/main.rs`
- Reads `WEBHOOK_SECRET` via `config::BridgeConfig::webhook_secret()`.
- Passes the secret to `webhook::run_webhook_server()`.
### `docker-compose.yml`
- Added `WEBHOOK_SECRET=${WEBHOOK_SECRET}` to environment section.
### `config.example.toml`
- Added comment documenting the `WEBHOOK_SECRET` env var.
### `README.md`
- Updated webhook URL example to include `?secret=` parameter.
- Added environment variables table documenting all three secrets.
### `Cargo.toml`
- Added `tower` (0.5, `util` feature) as dev dependency for handler tests.
## Follow-up Items
- None. All 65 tests pass.

View File

@@ -1,5 +1,7 @@
[irc]
server = "irc.zeronode.net"
# Set IRC_PASSWORD env var for server password (PASS command)
# username = "myuser"
port = 6667
tls = false
nick = "owncast-bridge"
@@ -8,6 +10,7 @@ channel = "#BowlAfterBowl"
[owncast]
url = "https://owncast.bowlafterbowl.com"
# Set OWNCAST_ACCESS_TOKEN env var for the token
# Set WEBHOOK_SECRET env var to require ?secret=<token> on incoming webhooks
webhook_port = 9078
websocket_enabled = true
health_poll_interval_secs = 30

View File

@@ -5,8 +5,10 @@ services:
restart: unless-stopped
environment:
- OWNCAST_ACCESS_TOKEN=${OWNCAST_ACCESS_TOKEN}
- IRC_PASSWORD=${IRC_PASSWORD}
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
- RUST_LOG=info
volumes:
- ./config.toml:/etc/owncast-irc-bridge/config.toml:ro
- ./config.toml:/etc/owncast-irc-bridge/config.toml:ro,z
ports:
- "9078:9078"

View File

@@ -21,6 +21,8 @@ pub struct IrcConfig {
pub tls: bool,
#[serde(default = "default_nick")]
pub nick: String,
#[serde(default)]
pub username: Option<String>,
pub channel: String,
}
@@ -60,10 +62,18 @@ impl BridgeConfig {
Ok(config)
}
pub fn irc_server_password() -> Option<String> {
std::env::var("IRC_PASSWORD").ok()
}
pub fn owncast_access_token(&self) -> anyhow::Result<String> {
std::env::var("OWNCAST_ACCESS_TOKEN")
.map_err(|_| anyhow::anyhow!("OWNCAST_ACCESS_TOKEN env var not set"))
}
pub fn webhook_secret() -> Option<String> {
std::env::var("WEBHOOK_SECRET").ok()
}
}
fn default_irc_port() -> u16 { 6667 }
@@ -100,6 +110,7 @@ impl BridgeConfig {
port: 6667,
tls: false,
nick: "test-bot".to_string(),
username: None,
channel: "#test".to_string(),
},
owncast: OwncastConfig {
@@ -134,6 +145,7 @@ url = "https://owncast.example.com"
assert_eq!(config.irc.port, 6667);
assert_eq!(config.irc.tls, false);
assert_eq!(config.irc.nick, "owncast-bridge");
assert!(config.irc.username.is_none());
assert_eq!(config.irc.channel, "#test");
assert_eq!(config.owncast.url, "https://owncast.example.com");
assert_eq!(config.owncast.webhook_port, 9078);
@@ -153,6 +165,7 @@ server = "irc.example.com"
port = 6697
tls = true
nick = "mybot"
username = "myident"
channel = "#mychan"
[owncast]
@@ -173,12 +186,21 @@ socket_path = "/var/run/bridge.sock"
assert_eq!(config.irc.port, 6697);
assert!(config.irc.tls);
assert_eq!(config.irc.nick, "mybot");
assert_eq!(config.irc.username.as_deref(), Some("myident"));
assert_eq!(config.owncast.webhook_port, 8888);
assert!(config.owncast.websocket_enabled);
assert_eq!(config.bridge.message_buffer_size, 50);
assert_eq!(config.control.socket_path, "/var/run/bridge.sock");
}
#[test]
fn test_irc_server_password_from_env() {
std::env::set_var("IRC_PASSWORD", "secret123");
assert_eq!(BridgeConfig::irc_server_password().as_deref(), Some("secret123"));
std::env::remove_var("IRC_PASSWORD");
assert!(BridgeConfig::irc_server_password().is_none());
}
#[test]
fn test_access_token_from_env() {
std::env::set_var("OWNCAST_ACCESS_TOKEN", "test-token-123");

View File

@@ -5,7 +5,7 @@ use irc::client::prelude::*;
use tokio::sync::mpsc;
use tracing::{error, info};
use crate::config::IrcConfig;
use crate::config::{BridgeConfig, IrcConfig};
use crate::events::{BridgeEvent, Source};
pub async fn run_irc_task(
@@ -52,6 +52,8 @@ async fn connect_and_run(
port: Some(config.port),
use_tls: Some(config.tls),
channels: vec![config.channel.clone()],
password: BridgeConfig::irc_server_password(),
username: config.username.clone(),
..Config::default()
};

View File

@@ -38,6 +38,7 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let config = config::BridgeConfig::load(&cli.config)?;
let access_token = config.owncast_access_token()?;
let webhook_secret = config::BridgeConfig::webhook_secret();
info!("Starting owncast-irc-bridge");
@@ -64,7 +65,7 @@ async fn main() -> anyhow::Result<()> {
let webhook_port = config.owncast.webhook_port;
let webhook_event_tx = event_tx.clone();
let _webhook_handle = tokio::spawn(async move {
if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx).await {
if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx, webhook_secret).await {
tracing::error!(error = %e, "Webhook server failed");
}
});

View File

@@ -1,4 +1,4 @@
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use axum::{extract::{Query, State}, http::StatusCode, routing::post, Json, Router};
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::{info, warn};
@@ -67,15 +67,32 @@ impl WebhookPayload {
}
}
#[derive(Deserialize)]
struct WebhookQuery {
secret: Option<String>,
}
#[derive(Clone)]
struct WebhookState {
event_tx: mpsc::Sender<BridgeEvent>,
secret: Option<String>,
}
async fn handle_webhook(
State(state): State<WebhookState>,
Query(query): Query<WebhookQuery>,
Json(payload): Json<WebhookPayload>,
) -> StatusCode {
if let Some(ref expected) = state.secret {
match query.secret {
Some(ref provided) if provided == expected => {}
_ => {
warn!("Webhook request rejected: invalid or missing secret");
return StatusCode::UNAUTHORIZED;
}
}
}
info!(event_type = %payload.event_type, "Received webhook");
match payload.into_bridge_event() {
@@ -93,8 +110,13 @@ async fn handle_webhook(
pub async fn run_webhook_server(
port: u16,
event_tx: mpsc::Sender<BridgeEvent>,
secret: Option<String>,
) -> anyhow::Result<()> {
let state = WebhookState { event_tx };
if secret.is_none() {
warn!("WEBHOOK_SECRET is not set — webhook endpoint accepts all requests");
}
let state = WebhookState { event_tx, secret };
let app = Router::new()
.route("/webhook", post(handle_webhook))
.with_state(state);
@@ -110,6 +132,91 @@ pub async fn run_webhook_server(
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn build_app(secret: Option<String>) -> (Router, mpsc::Receiver<BridgeEvent>) {
let (event_tx, rx) = mpsc::channel(16);
let state = WebhookState { event_tx, secret };
let app = Router::new()
.route("/webhook", post(handle_webhook))
.with_state(state);
(app, rx)
}
fn chat_body() -> String {
serde_json::json!({
"type": "CHAT",
"eventData": {
"user": { "displayName": "test", "isBot": false },
"body": "hi",
"id": "msg1",
"visible": true
}
})
.to_string()
}
#[tokio::test]
async fn test_auth_correct_secret_returns_200() {
let (app, _rx) = build_app(Some("mysecret".to_string()));
let resp = app
.oneshot(
Request::post("/webhook?secret=mysecret")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_auth_wrong_secret_returns_401() {
let (app, _rx) = build_app(Some("mysecret".to_string()));
let resp = app
.oneshot(
Request::post("/webhook?secret=wrong")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_auth_missing_secret_returns_401() {
let (app, _rx) = build_app(Some("mysecret".to_string()));
let resp = app
.oneshot(
Request::post("/webhook")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_auth_no_secret_configured_accepts_all() {
let (app, _rx) = build_app(None);
let resp = app
.oneshot(
Request::post("/webhook")
.header("content-type", "application/json")
.body(Body::from(chat_body()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn test_parse_chat_event() {