Skip to content
Closed
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
13 changes: 10 additions & 3 deletions input/chainsync/chainsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,10 +730,16 @@ func getKupoClient(c *ChainSync) (*kugo.Client, error) {
}

// Validate URL first
_, err := url.ParseRequestURI(c.kupoUrl)
kupoURL, err := url.ParseRequestURI(c.kupoUrl)
if err != nil {
return nil, fmt.Errorf("invalid kupo URL: %w", err)
}
if kupoURL.Scheme != "http" && kupoURL.Scheme != "https" {
return nil, fmt.Errorf("invalid kupo URL scheme: %s", kupoURL.Scheme)
}
if kupoURL.Host == "" {
return nil, errors.New("invalid kupo URL host")
}

KugoCustomLogger := logging.NewKugoCustomLogger(logging.LevelInfo)

Expand All @@ -748,17 +754,18 @@ func getKupoClient(c *ChainSync) (*kugo.Client, error) {
Timeout: 2 * time.Second,
}

healthUrl := strings.TrimRight(c.kupoUrl, "/") + "/health"
healthURL := kupoURL.JoinPath("health")

// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthUrl, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create health check request: %w", err)
}

// #nosec G704 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
Comment on lines +768 to 769
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same incorrect gosec rule ID G704 — should be G107.

Same issue as noted in input/mempool/mempool.go. The rule for HTTP request URL taint input is G107.

🔧 Proposed fix
-	// `#nosec` G704 -- Kupo endpoint is user-configured and validated before use.
+	// `#nosec` G107 -- Kupo endpoint is user-configured and validated before use.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #nosec G704 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
// `#nosec` G107 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@input/chainsync/chainsync.go` around lines 768 - 769, Replace the incorrect
gosec suppression tag above the HTTP call: change the comment "// `#nosec` G704 --
Kupo endpoint is user-configured and validated before use." to use the correct
rule ID "// `#nosec` G107 -- Kupo endpoint is user-configured and validated before
use." so the suppression applies to the tainted URL rule around the
httpClient.Do(req) call (the line where resp, err := httpClient.Do(req) is
invoked).

if err != nil {
// Handle different error types
Expand Down
1 change: 1 addition & 0 deletions input/mempool/mempool.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ func (m *Mempool) getKupoClient() (*kugo.Client, error) {
return nil, fmt.Errorf("failed to create health check request: %w", err)
}
httpClient := &http.Client{Timeout: kupoHealthTimeout}
// #nosec G704 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
if err != nil {
switch {
Expand Down
15 changes: 8 additions & 7 deletions output/push/fcm/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
)

type Message struct {
Expand Down Expand Up @@ -73,25 +74,24 @@ func NewMessage(token string, opts ...MessageOption) (*Message, error) {
}

func Send(accessToken string, projectId string, msg *Message) error {
fcmEndpoint := fmt.Sprintf(
"https://fcm.googleapis.com/v1/projects/%s/messages:send",
projectId,
)
fcmURL := &url.URL{
Scheme: "https",
Host: "fcm.googleapis.com",
Path: fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

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

P2: projectId is interpolated into the URL path without escaping. A projectId containing / or .. segments (e.g., "foo/../../v2") would result in path traversal. Use url.PathEscape(projectId) to prevent this.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At output/push/fcm/message.go, line 80:

<comment>`projectId` is interpolated into the URL path without escaping. A `projectId` containing `/` or `..` segments (e.g., `"foo/../../v2"`) would result in path traversal. Use `url.PathEscape(projectId)` to prevent this.</comment>

<file context>
@@ -73,25 +74,24 @@ func NewMessage(token string, opts ...MessageOption) (*Message, error) {
+	fcmURL := &url.URL{
+		Scheme: "https",
+		Host:   "fcm.googleapis.com",
+		Path:   fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
+	}
 
</file context>
Suggested change
Path: fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
Path: fmt.Sprintf("/v1/projects/%s/messages:send", url.PathEscape(projectId)),
Fix with Cubic

}
Comment on lines +77 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -g 'go.mod' --exec cat {}

Repository: blinklabs-io/adder

Length of output: 4886


🏁 Script executed:

cat -n output/push/fcm/message.go | sed -n '70,115p'

Repository: blinklabs-io/adder

Length of output: 1411


projectId interpolated into URL path without escaping — #nosec justification is inaccurate.

url.URL.Path stores the unescaped path; String() does not percent-encode /, so a projectId containing slashes or .. segments is treated as literal path components. For example, projectId = "foo/../../v2" would result in /v1/projects/foo/../../v2/messages:send on the wire.

The #nosec G704 comment claims "validated path," but no validation or escaping is applied — this misrepresents the code's security posture.

Fix with url.PathEscape:

-Path:   fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
+Path:   fmt.Sprintf("/v1/projects/%s/messages:send", url.PathEscape(projectId)),

Or use url.JoinPath (Go 1.19+, which your project supports):

fcmURL, err := url.JoinPath("https://fcm.googleapis.com", "v1/projects", projectId, "messages:send")
if err != nil {
    return err
}

Update the #nosec comment to reflect the actual mitigation:

-// `#nosec` G704 -- Request targets the fixed FCM host with a validated path.
+// `#nosec` G704 -- Request targets a fixed external host; projectId is path-escaped.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@output/push/fcm/message.go` around lines 77 - 81, The URL path is built by
interpolating projectId into url.URL.Path which leaves it unescaped and the
current `#nosec G704` is incorrect; fix by percent-escaping or using safe join:
replace the manual url.URL construction for fcmURL with a call that escapes
projectId (e.g., use url.PathEscape(projectId) when building the Path or better
use url.JoinPath("https://fcm.googleapis.com", "v1/projects", projectId,
"messages:send") and handle the returned error), and update/remove the `#nosec`
comment so it accurately reflects the mitigation applied; locate and change the
code that constructs fcmURL and the `projectId` usage in message.go.


// Convert the message to JSON
payload, err := json.Marshal(msg)
if err != nil {
return err
}

fmt.Println(string(payload))

// Create a new HTTP request
ctx := context.Background()
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
fcmEndpoint,
fcmURL.String(),
bytes.NewBuffer(payload),
)
if err != nil {
Expand All @@ -104,6 +104,7 @@ func Send(accessToken string, projectId string, msg *Message) error {

// Execute the request
client := &http.Client{}
// #nosec G704 -- Request targets the fixed FCM host with a validated path.
resp, err := client.Do(req)
if err != nil {
return err
Expand Down
1 change: 1 addition & 0 deletions output/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ func (w *WebhookOutput) SendWebhook(e *event.Event) error {
}
client := &http.Client{Transport: customTransport}
// Send payload
// #nosec G704 -- Webhook URL is user-configured and intentionally allowed.
resp, err := client.Do(req)
Comment on lines +335 to 336
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the webhook.go file to see the context around lines 320-350
fd -n "webhook.go" | head -20

Repository: blinklabs-io/adder

Length of output: 292


🏁 Script executed:

# Get the actual file paths to locate webhook.go
git ls-files | grep -i webhook

Repository: blinklabs-io/adder

Length of output: 138


🏁 Script executed:

# Once we have the path, examine the code context
cat -n output/webhook/webhook.go | sed -n '320,350p'

Repository: blinklabs-io/adder

Length of output: 1378


🏁 Script executed:

# Also check line 323 to verify the existing nosec pattern
cat -n output/webhook/webhook.go | sed -n '320,330p'

Repository: blinklabs-io/adder

Length of output: 654


🌐 Web query:

G704 gosec rule SSRF taint analysis

💡 Result:

gosec rule G704 is the SSRF (Server-Side Request Forgery) detector implemented as a taint-analysis rule (G7xx = taint rules). [3]

What it flags (conceptually)

G704 reports an issue when tainted (externally influenced) data can reach a network/HTTP “sink” that initiates an outbound request / connection. [1][3]

Taint sources used by G704

G704’s SSRF taint config treats data as tainted when it originates from, or flows from: [1]

  • *net/http.Request (as a parameter type source)
  • os.Args and os.Getenv (function sources)
  • *bufio.Reader, *bufio.Scanner (I/O sources)

Sinks checked by G704 (where tainted data is dangerous)

The rule checks specific arguments (“CheckArgs”) for taint at these sinks, including: [1]

  • net/http: http.Get, Post, Head, PostForm (URL argument)
  • net/http: NewRequest, NewRequestWithContext (URL argument position)
  • (*http.Client): Do, Get, Post, Head
  • net: Dial, DialTimeout (address argument), LookupHost (host argument)
  • net/http/httputil: NewSingleHostReverseProxy (target URL argument)

Sanitizers

The SSRF rule config effectively ships with no standard-library sanitizers (it explicitly notes that parsing like url.Parse does not make something SSRF-safe because it doesn’t enforce an allowlist). [1]

Examples (from gosec tests)

  • Flagged: user-controlled query param used as http.Get(url) [2]
  • Flagged: os.Getenv("TARGET_URL") used as http.Post(target, ...) [2]
  • Not flagged: hardcoded URL in http.Get("https://api.example.com/data") [2]

🏁 Script executed:

# Search for URL validation patterns in chainsync and mempool modules mentioned in the review
fd -n "\.go$" | xargs rg -l "url\.Parse|scheme.*http|urlutil" | head -20

Repository: blinklabs-io/adder

Length of output: 402


🏁 Script executed:

# Check for URL scheme validation in the codebase
rg "Scheme.*http|scheme.*http" -A 2 -B 2 | head -50

Repository: blinklabs-io/adder

Length of output: 1100


🏁 Script executed:

# Find where w.url is set in webhook.go
cat -n output/webhook/webhook.go | grep -E "w\.url|\.url\s*=" | head -20

Repository: blinklabs-io/adder

Length of output: 258


🏁 Script executed:

# Look at the WebhookOutput struct definition to see w.url
rg "type WebhookOutput" -A 10 output/webhook/webhook.go

Repository: blinklabs-io/adder

Length of output: 353


🏁 Script executed:

# Check mempool.go for URL validation
cat -n input/mempool/mempool.go | sed -n '1,100p' | grep -E "Scheme|http|https"

Repository: blinklabs-io/adder

Length of output: 137


🏁 Script executed:

# Get a broader view of mempool URL handling
rg "http\.|url\.|Parse" -B 2 -A 2 input/mempool/mempool.go | head -40

Repository: blinklabs-io/adder

Length of output: 1056


🏁 Script executed:

# Check the SendWebhook function signature and full context
cat -n output/webhook/webhook.go | sed -n '300,360p'

Repository: blinklabs-io/adder

Length of output: 2203


🏁 Script executed:

# Find where WebhookOutput is instantiated or w.url is set
rg "WebhookOutput\{|\.url\s*=" output/webhook/ -B 2 -A 2

Repository: blinklabs-io/adder

Length of output: 613


🏁 Script executed:

# Check the options or initialization code
cat -n output/webhook/options.go | head -80

Repository: blinklabs-io/adder

Length of output: 2379


🏁 Script executed:

# Look at where webhook URL comes from in configuration
rg "url" output/webhook/options.go -B 2 -A 2

Repository: blinklabs-io/adder

Length of output: 239


Address the response body leak and consider adding URL validation for consistency with other modules.

The G704 suppression is justified for a config-only URL, but two issues remain:

  1. Response body leak (lines 343–347): The defer resp.Body.Close() is placed after io.ReadAll(). If io.ReadAll() fails, the early return at line 345 exits without ever closing the body, causing a resource leak. Move the defer before the ReadAll call.

  2. Missing URL validation: Other modules in this PR (chainsync, mempool) added explicit URL scheme validation before making HTTP calls (e.g., rejecting non-http/https schemes). The webhook output applies no such check to w.url. While the config-only justification for the nosec is sound, adding validation would improve consistency and defense-in-depth, especially since mempool's nosec comment explicitly notes "validated before use."

Example fix
 	respBody, err := io.ReadAll(resp.Body)
+	defer resp.Body.Close()
 	if err != nil {
 		return fmt.Errorf("%w", err)
 	}
-	defer resp.Body.Close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@output/webhook/webhook.go` around lines 335 - 336, Move the response body
close so it cannot leak: after calling client.Do(req) and before calling
io.ReadAll, immediately check err and then defer resp.Body.Close() (so resp.Body
is always closed even if ReadAll fails); also add URL scheme validation for
w.url (the value used to build the request) similar to chainsync/mempool modules
by parsing w.url and rejecting any non-http/https scheme prior to making
client.Do(req) to ensure consistent defense-in-depth.

if err != nil {
return fmt.Errorf("%w", err)
Expand Down
180 changes: 180 additions & 0 deletions tray/adder_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright 2026 Blink Labs Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package tray

import (
"errors"
"fmt"
"os"
"path/filepath"

"gopkg.in/yaml.v3"
)

// MonitorTemplate represents a pre-configured monitoring template.
type MonitorTemplate int

const (
WatchWallet MonitorTemplate = iota
TrackDRep
MonitorPool
)

// String returns the display name of the template.
func (t MonitorTemplate) String() string {
switch t {
case WatchWallet:
return "Watch Wallet"
case TrackDRep:
return "Track DRep"
case MonitorPool:
return "Monitor Pool"
default:
return "Unknown"
}
}

// AdderConfigParams holds the parameters for generating an adder
// configuration.
type AdderConfigParams struct {
Network string
Template MonitorTemplate
Param string // The address/drep/pool value depending on Template
Output string // Output plugin name (default: "log")
Format string // Output format (default: "json")
}

// adderConfig is the internal structure matching adder's config.yaml
// format. We use map types for the plugins section to match adder's
// flexible config.
type adderConfig struct {
Input string `yaml:"input"`
Output string `yaml:"output"`
API adderAPIConfig `yaml:"api"`
Logging adderLoggingConfig `yaml:"logging"`
Plugins map[string]map[string]interface{} `yaml:"plugins"`
}

type adderAPIConfig struct {
Address string `yaml:"address"`
Port uint `yaml:"port"`
}

type adderLoggingConfig struct {
Level string `yaml:"level"`
}

// GenerateAdderConfig builds an adder configuration from the given
// parameters. The API server is always enabled because adder-tray
// connects to its /events endpoint for desktop notifications and
// /healthcheck for status monitoring.
func GenerateAdderConfig(params AdderConfigParams) ([]byte, error) {
if params.Network == "" {
return nil, errors.New("network is required")
}
if params.Param == "" {
return nil, errors.New("filter parameter is required")
}

output := params.Output
if output == "" {
output = "log"
}
format := params.Format
if format == "" {
format = "json"
}

cfg := adderConfig{
Input: "chainsync",
Output: output,
API: adderAPIConfig{
Address: "127.0.0.1",
Port: 8080,
},
Logging: adderLoggingConfig{
Level: "info",
},
Plugins: map[string]map[string]interface{}{
"input": {
"chainsync": map[string]interface{}{
"network": params.Network,
},
},
"output": {
output: map[string]interface{}{
"format": format,
},
},
},
}

// Add filter config based on template
filterKey := templateFilterKey(params.Template)
if filterKey == "" {
return nil, fmt.Errorf("unsupported monitor template: %d", params.Template)
}
cfg.Plugins["filter"] = map[string]interface{}{
"chainsync": map[string]interface{}{
filterKey: params.Param,
},
}

return yaml.Marshal(&cfg)
}

// templateFilterKey returns the filter configuration key for a
// template. Returns an empty string for unrecognized templates.
func templateFilterKey(t MonitorTemplate) string {
switch t {
case WatchWallet:
return "address"
case TrackDRep:
return "drep"
case MonitorPool:
return "pool"
default:
return ""
}
}

// AdderConfigPath returns the path to the adder config file in the
// config directory.
func AdderConfigPath() string {
return filepath.Join(ConfigDir(), "config.yaml")
}

// WriteAdderConfig writes the adder configuration to the config
// directory.
func WriteAdderConfig(params AdderConfigParams) error {
data, err := GenerateAdderConfig(params)
if err != nil {
return fmt.Errorf("generating config: %w", err)
}

dir := ConfigDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return fmt.Errorf("setting config directory permissions: %w", err)
}

if err := os.WriteFile(AdderConfigPath(), data, 0o600); err != nil {
return fmt.Errorf("writing config file: %w", err)
}

return nil
}
Loading