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
2 changes: 1 addition & 1 deletion .github/workflows/security-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ jobs:
- name: Upload Trivy SARIF to GitHub Security
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
# github/codeql-action v4
uses: github/codeql-action/upload-sarif@7dd76e6bf79d24133aa649887a6ee01d8b063816
uses: github/codeql-action/upload-sarif@fd1ca02d0ddf5bf468c79e6ffb6ffb24f0ecba37
with:
sarif_file: 'trivy-binary-results.sarif'
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Pushover Notification Provider**: Send push notifications to your devices via the Pushover app
- Supports JSON templates (minimal, detailed, custom)
- Application API Token stored securely — never exposed in API responses
- User Key stored in the URL field, following the same pattern as Telegram
- Feature flag: `feature.notifications.service.pushover.enabled` (on by default)
- Emergency priority (2) is intentionally unsupported — deferred to a future release

- **Slack Notification Provider**: Send alerts to Slack channels via Incoming Webhooks
- Supports JSON templates (minimal, detailed, custom) with Slack's native `text` format
- Webhook URL stored securely — never exposed in API responses
Expand Down
4 changes: 2 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.34
github.com/mattn/go-sqlite3 v1.14.37
github.com/oschwald/geoip2-golang/v2 v2.1.0
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
Expand Down Expand Up @@ -98,5 +98,5 @@ require (
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
modernc.org/sqlite v1.46.2 // indirect
)
8 changes: 4 additions & 4 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
Expand Down Expand Up @@ -263,8 +263,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
Expand Down
8 changes: 4 additions & 4 deletions backend/internal/api/handlers/notification_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1003,14 +1003,14 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
existing := models.NotificationProvider{
ID: "unsupported-type",
Name: "Custom Provider",
Type: "pushover",
URL: "https://pushover.example.com/test",
Type: "sms",
URL: "https://sms.example.com/test",
}
require.NoError(t, db.Create(&existing).Error)

payload := map[string]any{
"name": "Updated Pushover Provider",
"url": "https://pushover.example.com/updated",
"name": "Updated SMS Provider",
"url": "https://sms.example.com/updated",
}
body, _ := json.Marshal(payload)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
requestFunc: func(id string) (*http.Request, gin.Params) {
payload := map[string]interface{}{
"name": "Test",
"type": "pushover",
"type": "sms",
"url": "https://example.com",
}
body, _ := json.Marshal(payload)
Expand Down
16 changes: 13 additions & 3 deletions backend/internal/api/handlers/notification_provider_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
}

providerType := strings.ToLower(strings.TrimSpace(req.Type))
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" {
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" {
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
return
}
Expand Down Expand Up @@ -242,12 +242,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
}

providerType := strings.ToLower(strings.TrimSpace(existing.Type))
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" {
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" {
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
return
}

if (providerType == "gotify" || providerType == "telegram" || providerType == "slack") && strings.TrimSpace(req.Token) == "" {
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover") && strings.TrimSpace(req.Token) == "" {
// Keep existing token if update payload omits token
req.Token = existing.Token
}
Expand Down Expand Up @@ -326,6 +326,16 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) {
return
}

if providerType == "telegram" && strings.TrimSpace(req.Token) != "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Telegram bot token is accepted only on provider create/update")
return
}

if providerType == "pushover" && strings.TrimSpace(req.Token) != "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Pushover API token is accepted only on provider create/update")
return
}

// Email providers use global SMTP + recipients from the URL field; they don't require a saved provider ID.
if providerType == "email" {
provider := models.NotificationProvider{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,35 @@ func TestNotificationProviderHandler_List_TelegramNeverExposesBotToken(t *testin
_, hasTokenField := raw[0]["token"]
assert.False(t, hasTokenField, "raw token field must not appear in JSON response")
}

func TestNotificationProviderHandler_Test_TelegramTokenRejected(t *testing.T) {
r, _ := setupNotificationProviderTest(t)

payload := map[string]any{
"type": "telegram",
"token": "bot123:TOKEN",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
}

func TestNotificationProviderHandler_Test_PushoverTokenRejected(t *testing.T) {
r, _ := setupNotificationProviderTest(t)

payload := map[string]any{
"type": "pushover",
"token": "app-token-abc",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored(t *testing
db := SetupCompatibilityTestDB(t)

// Create ONLY unsupported providers
unsupportedTypes := []string{"pushover", "generic"}
unsupportedTypes := []string{"sms", "generic"}

for _, providerType := range unsupportedTypes {
provider := &models.NotificationProvider{
Expand Down
1 change: 1 addition & 0 deletions backend/internal/notifications/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const (
FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled"
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled"
FlagPushoverServiceEnabled = "feature.notifications.service.pushover.enabled"
FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled"
)
4 changes: 4 additions & 0 deletions backend/internal/notifications/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) boo
return flags[FlagWebhookServiceEnabled]
case "telegram":
return flags[FlagTelegramServiceEnabled]
case "slack":
return flags[FlagSlackServiceEnabled]
case "pushover":
return flags[FlagPushoverServiceEnabled]
default:
return false
}
Expand Down
36 changes: 36 additions & 0 deletions backend/internal/notifications/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,39 @@ func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) {
t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false")
}
}

func TestRouter_ShouldUseNotify_SlackServiceFlag(t *testing.T) {
router := NewRouter()

flags := map[string]bool{
FlagNotifyEngineEnabled: true,
FlagSlackServiceEnabled: true,
}

if !router.ShouldUseNotify("slack", flags) {
t.Fatalf("expected notify routing enabled for slack when FlagSlackServiceEnabled is true")
}

flags[FlagSlackServiceEnabled] = false
if router.ShouldUseNotify("slack", flags) {
t.Fatalf("expected notify routing disabled for slack when FlagSlackServiceEnabled is false")
}
}

func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) {
router := NewRouter()

flags := map[string]bool{
FlagNotifyEngineEnabled: true,
FlagPushoverServiceEnabled: true,
}

if !router.ShouldUseNotify("pushover", flags) {
t.Fatalf("expected notify routing enabled for pushover when FlagPushoverServiceEnabled is true")
}

flags[FlagPushoverServiceEnabled] = false
if router.ShouldUseNotify("pushover", flags) {
t.Fatalf("expected notify routing disabled for pushover when FlagPushoverServiceEnabled is false")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func (s *EnhancedSecurityNotificationService) getProviderAggregatedConfig() (*mo
"slack": true,
"gotify": true,
"telegram": true,
"pushover": true,
}
filteredProviders := []models.NotificationProvider{}
for _, p := range providers {
Expand Down
54 changes: 51 additions & 3 deletions backend/internal/services/notification_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type NotificationService struct {
httpWrapper *notifications.HTTPWrapper
mailService MailServiceInterface
telegramAPIBaseURL string
pushoverAPIBaseURL string
validateSlackURL func(string) error
}

Expand All @@ -50,6 +51,7 @@ func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts
httpWrapper: notifications.NewNotifyHTTPWrapper(),
mailService: mailService,
telegramAPIBaseURL: "https://api.telegram.org",
pushoverAPIBaseURL: "https://api.pushover.net",
validateSlackURL: validateSlackWebhookURL,
}
for _, opt := range opts {
Expand Down Expand Up @@ -127,7 +129,7 @@ func validateDiscordProviderURL(providerType, rawURL string) error {
// supportsJSONTemplates returns true if the provider type can use JSON templates
func supportsJSONTemplates(providerType string) bool {
switch strings.ToLower(providerType) {
case "webhook", "discord", "gotify", "slack", "generic", "telegram":
case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover":
return true
default:
return false
Expand All @@ -136,7 +138,7 @@ func supportsJSONTemplates(providerType string) bool {

func isSupportedNotificationProviderType(providerType string) bool {
switch strings.ToLower(strings.TrimSpace(providerType)) {
case "discord", "email", "gotify", "webhook", "telegram", "slack":
case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover":
return true
default:
return false
Expand All @@ -157,6 +159,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool {
return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true)
case "slack":
return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true)
case "pushover":
return s.getFeatureFlagValue(notifications.FlagPushoverServiceEnabled, true)
default:
return false
}
Expand Down Expand Up @@ -507,9 +511,18 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti
return fmt.Errorf("telegram payload requires 'text' field")
}
}
case "pushover":
if _, hasMessage := jsonPayload["message"]; !hasMessage {
return fmt.Errorf("pushover payload requires 'message' field")
}
if priority, ok := jsonPayload["priority"]; ok {
if p, isFloat := priority.(float64); isFloat && p == 2 {
return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported")
}
}
}

if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" {
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" {
headers := map[string]string{
"Content-Type": "application/json",
"User-Agent": "Charon-Notify/1.0",
Expand Down Expand Up @@ -566,6 +579,41 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti
dispatchURL = decryptedWebhookURL
}

if providerType == "pushover" {
decryptedToken := p.Token
if strings.TrimSpace(decryptedToken) == "" {
return fmt.Errorf("pushover API token is not configured")
}
if strings.TrimSpace(p.URL) == "" {
return fmt.Errorf("pushover user key is not configured")
}

pushoverBase := s.pushoverAPIBaseURL
if pushoverBase == "" {
pushoverBase = "https://api.pushover.net"
}
dispatchURL = pushoverBase + "/1/messages.json"

parsedURL, parseErr := neturl.Parse(dispatchURL)
expectedHost := "api.pushover.net"
if parsedURL != nil && parsedURL.Hostname() != "" && pushoverBase != "https://api.pushover.net" {
expectedHost = parsedURL.Hostname()
}
if parseErr != nil || parsedURL.Hostname() != expectedHost {
return fmt.Errorf("pushover dispatch URL validation failed: invalid hostname")
}

jsonPayload["token"] = decryptedToken
jsonPayload["user"] = p.URL

updatedBody, marshalErr := json.Marshal(jsonPayload)
if marshalErr != nil {
return fmt.Errorf("failed to marshal pushover payload: %w", marshalErr)
}
body.Reset()
body.Write(updatedBody)
}

if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{
URL: dispatchURL,
Headers: headers,
Expand Down
Loading
Loading