Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions db/Project.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Project struct {
Created time.Time `db:"created" json:"created" backup:"-"`
Alert bool `db:"alert" json:"alert,omitempty"`
AlertChat *string `db:"alert_chat" json:"alert_chat,omitempty"`
AlertPushoverToken *string `db:"alert_pushover_token" json:"alert_pushover_token,omitempty"`
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
MaxParallelTasks int `db:"max_parallel_tasks" json:"max_parallel_tasks,omitempty"`
Type string `db:"type" json:"type"`
DefaultSecretStorageID *int `db:"default_secret_storage_id" json:"default_secret_storage_id,omitempty" backup:"-"`
Expand Down
1 change: 1 addition & 0 deletions db/sql/migrations/v2.18.15.err.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ drop index if exists `task__workflow_run_id`;
alter table `task` drop column `artifacts`;
alter table `task` drop column `workflow_node_id`;
alter table `task` drop column `workflow_run_id`;
alter table `project` drop column `alert_pushover_token`;

drop index if exists `project__workflow_approval__run_id`;
drop index if exists `project__workflow_approval__project_id`;
Expand Down
2 changes: 2 additions & 0 deletions db/sql/migrations/v2.18.15.sql
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,7 @@ alter table `task` add column `workflow_run_id` int null;
alter table `task` add column `workflow_node_id` int null;
alter table `task` add column `artifacts` text null;

alter table `project` add column `alert_pushover_token` varchar(50) default '';

create index `task__workflow_run_id` on `task`(`workflow_run_id`);
create index `task__workflow_node_id` on `task`(`workflow_node_id`);
7 changes: 4 additions & 3 deletions db/sql/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ func (d *SqlDb) CreateProject(project db.Project) (newProject db.Project, err er

insertId, err := d.insert(
"id",
"insert into project(name, created, type, alert, alert_chat, max_parallel_tasks) values (?, ?, ?, ?, ?, ?)",
project.Name, project.Created, project.Type, project.Alert, project.AlertChat, project.MaxParallelTasks)
"insert into project(name, created, type, alert, alert_chat, alert_pushover_token, max_parallel_tasks) values (?, ?, ?, ?, ?, ?, ?)",
project.Name, project.Created, project.Type, project.Alert, project.AlertChat, project.AlertPushoverToken, project.MaxParallelTasks)

if err != nil {
return
Expand Down Expand Up @@ -111,10 +111,11 @@ func (d *SqlDb) DeleteProject(projectID int) error {

func (d *SqlDb) UpdateProject(project db.Project) error {
_, err := d.exec(
"update project set name=?, alert=?, alert_chat=?, max_parallel_tasks=? where id=?",
"update project set name=?, alert=?, alert_chat=?, alert_pushover_token=?, max_parallel_tasks=? where id=?",
project.Name,
project.Alert,
project.AlertChat,
project.AlertPushoverToken,
project.MaxParallelTasks,
project.ID)
return err
Expand Down
12 changes: 7 additions & 5 deletions services/tasks/TaskRunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ type TaskRunner struct {
currentOutput *db.TaskOutput
currentState any

users []int
alert bool
alertChat *string
pool *TaskPool
keyInstaller db_lib.AccessKeyInstaller
users []int
alert bool
alertChat *string
alertPushoverToken *string
pool *TaskPool
keyInstaller db_lib.AccessKeyInstaller

// job executes Ansible and returns stdout to Semaphore logs
job Job
Expand Down Expand Up @@ -416,6 +417,7 @@ func (t *TaskRunner) populateDetails() error {

t.alert = project.Alert
t.alertChat = project.AlertChat
t.alertPushoverToken = project.AlertPushoverToken

// get project users
projectUsers, err := t.pool.store.GetProjectUsers(t.Template.ProjectID, db.RetrieveQueryParams{})
Expand Down
1 change: 1 addition & 0 deletions services/tasks/TaskRunner_logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (t *TaskRunner) SetStatus(status task_logger.TaskStatus) {
t.sendMicrosoftTeamsAlert()
t.sendDingTalkAlert()
t.sendGotifyAlert()
t.sendPushoverAlert()
}

for _, l := range t.statusListeners {
Expand Down
92 changes: 87 additions & 5 deletions services/tasks/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ var templates embed.FS

// Alert represents an alert that will be templated and sent to the appropriate service
type Alert struct {
Name string
Author string
Color string
Task alertTask
Chat alertChat
Name string
Author string
Color string
Task alertTask
Chat alertChat
Pushover alertPushover
}

type alertTask struct {
Expand All @@ -39,6 +40,11 @@ type alertChat struct {
ID string
}

type alertPushover struct {
Token string // application/API token
User string // global user OR target group key
}

func (t *TaskRunner) sendMailAlert() {
if !util.Config.EmailAlert || !t.alert {
return
Expand Down Expand Up @@ -190,6 +196,82 @@ func (t *TaskRunner) sendTelegramAlert() {
}
}

func (t *TaskRunner) sendPushoverAlert() {
if !util.Config.PushoverAlert || !t.alert {
return
}

if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus {
return
}

// Token: per-project GUI override wins over global config.
token := util.Config.PushoverAPIToken
if t.alertPushoverToken != nil && *t.alertPushoverToken != "" {
token = *t.alertPushoverToken
}

user := util.Config.PushoverUserGroupKey

if token == "" || user == "" {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should put a t.Log here so it's easier to debug in case util.Config.PushoverAlert is set to true but token or user is empty.

}

body := bytes.NewBufferString("")
author, version := t.alertInfos()

alert := Alert{
Name: t.Template.Name,
Author: author,
Color: t.alertColor("pushover"),
Task: alertTask{
ID: strconv.Itoa(t.Task.ID),
URL: t.taskLink(),
Result: t.Task.Status.Format(),
Version: version,
Desc: t.Task.Message,
},
Pushover: alertPushover{Token: token, User: user},
}

tpl, err := template.ParseFS(templates, "templates/pushover.tmpl")

@wahyudibo wahyudibo Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the pushover message is sent as JSON body, i don't recommend using a template for various reasons, from a practical to a security perspective

  1. Using struct and JSON marshal is simpler to implement than using a template. We can pass a struct like
type pushoverPayload struct {
	Token    string `json:"token"`
	User     string `json:"user"`
	Title    string `json:"title"`
	Message  string `json:"message"`
	URL      string `json:"url,omitempty"`
	URLTitle string `json:"url_title,omitempty"`
}

JSON-marshall it and pass it to the request body.
2. Performance-wise, parsing a template is more expensive than JSON marshalling.
3. The JSON body will break if any of the template values contain double quotes, since it's not parsed automatically by the template because it's a plain text template. JSON marshalling fixes this issue
4. As mentioned by cursor comment above, using a template for JSON body opens a vulnerability due to JSON injection


Another thing is the message field, which contains HTML text for its value and html field is set to 1.

"message": "<b>{{ .Task.Result }}</b> {{ .Task.Version }} - {{ .Task.Desc }}\nby {{ .Author }}",

Since the value also expects input from the user, it's a security-aware practice to sanitize the input first:

message := fmt.Sprintf("<b>%s</b> %s - %s\nby %s",
	html.EscapeString(t.Task.Status.Format()),
	html.EscapeString(version),
	html.EscapeString(t.Task.Message),
	html.EscapeString(author),
)


if err != nil {
t.Log("Can't parse pushover alert template!")
panic(err)
}

if err := tpl.Execute(body, alert); err != nil {
t.Log("Can't generate pushover alert template!")
panic(err)
}

if body.Len() == 0 {
t.Log("Buffer for pushover alert is empty")
return
}

t.Log("Attempting to send pushover alert")

resp, err := http.Post(
"https://api.pushover.net/1/messages.json",
"application/json",
body,
)

if err != nil {
t.Log("Can't send pushover alert! Error: " + err.Error())
} else if resp.StatusCode != 200 {

@wahyudibo wahyudibo Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm being nitpicky here, but i think we can change hardcoded 200 with http.StatusOK which can be understandable at a glance

t.Log("Can't send pushover alert! Response code: " + strconv.Itoa(resp.StatusCode))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use t.Logf to avoid using strconv.Itoa

t.Logf("Can't send pushover alert! Response code: %d", resp.StatusCode)

} else {
t.Log("Sent successfully pushover alert")
}

if resp != nil {
defer resp.Body.Close() //nolint:errcheck
}
}

func (t *TaskRunner) sendSlackAlert() {
if !util.Config.SlackAlert || !t.alert {
return
Expand Down
8 changes: 5 additions & 3 deletions services/tasks/alert_test_sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ func SendProjectTestAlerts(project db.Project, store db.Store) (err error) {
Name: "Test Notification",
Type: db.TemplateTask,
},
users: userIDs,
alert: project.Alert,
alertChat: project.AlertChat,
users: userIDs,
alert: project.Alert,
alertChat: project.AlertChat,
alertPushoverToken: project.AlertPushoverToken,
pool: &TaskPool{
logger: make(chan logRecord, 100),
store: store,
Expand All @@ -46,6 +47,7 @@ func SendProjectTestAlerts(project db.Project, store db.Store) (err error) {
tr.sendMicrosoftTeamsAlert()
tr.sendDingTalkAlert()
tr.sendGotifyAlert()
tr.sendPushoverAlert()
tr.sendMailAlert()

return
Expand Down
9 changes: 9 additions & 0 deletions services/tasks/templates/pushover.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"token": "{{ .Pushover.Token }}",
"user": "{{ .Pushover.User }}",
"title": "{{ .Name }} #{{ .Task.ID }}: {{ .Task.Result }}",
"html": 1,
"message": "<b>{{ .Task.Result }}</b> {{ .Task.Version }} - {{ .Task.Desc }}\nby {{ .Author }}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Medium — JSON injection / alert manipulation

This template builds JSON with text/template and no escaping. {{ .Task.Desc }} is populated from Task.Message, which task starters can set via POST /api/project/{id}/tasks (CanRunProjectTasks). Template name and author are also attacker-influenced.

Attack path: start a task with a crafted message such as x"}, "user": "<attacker_pushover_key>", "priority": 2, "x": ". When the alert fires, injected keys can override Pushover API fields (recipient user, priority, etc.) because duplicate JSON keys are commonly last-wins.

Impact: redirect operational alerts to an attacker-controlled Pushover account (information disclosure) or manipulate notification metadata; phishing via injected url is also possible depending on parser behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Medium — JSON injection / alert hijacking

This template builds JSON with text/template and no escaping. {{ .Task.Desc }} is populated from Task.Message, which task starters supply via POST /api/project/{id}/tasks (requires CanRunProjectTasks). {{ .Author }} and template {{ .Name }} are also attacker-influenced.

Attack path: start a task with a crafted message such as x"}, "user": "<attacker_pushover_key>", "priority": 2, "x": ". When the alert fires, injected keys appear after the legitimate user field; duplicate-key parsers (including typical JSON decoders) are last-wins, so the notification is redirected to the attacker's Pushover account using the server's application token.

Impact: operational alert hijacking (information disclosure) and possible phishing via injected url/title fields.

Fix: build the request with encoding/json (or at minimum json template func escaping) instead of hand-assembled JSON strings.

"url": "{{ .Task.URL }}",
"url_title": "View task in Semaphore"
}
4 changes: 4 additions & 0 deletions util/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,10 @@ type ConfigType struct {
GotifyUrl string `json:"gotify_url,omitempty" env:"SEMAPHORE_GOTIFY_URL"`
GotifyToken string `json:"gotify_token,omitempty" env:"SEMAPHORE_GOTIFY_TOKEN,sensitive"`

PushoverAlert bool `json:"pushover_alert,omitempty" env:"SEMAPHORE_PUSHOVER_ALERT"`
PushoverUserGroupKey string `json:"pushover_user_group_key,omitempty" env:"SEMAPHORE_PUSHOVER_USER_GROUP_KEY"`
PushoverAPIToken string `json:"pushover_api_token,omitempty" env:"SEMAPHORE_PUSHOVER_API_TOKEN,sensitive"`

// oidc settings
OidcProviders map[string]OidcProvider `json:"oidc_providers,omitempty" env:"SEMAPHORE_OIDC_PROVIDERS"`

Expand Down
Loading
Loading