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
3 changes: 1 addition & 2 deletions addons/automation/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve
if modelName == "" || !ok || resID <= 0 {
return nil
}
bypass := orm.ContextWithBypass(ctx, true)
return orm.UpdateRecordByID(bypass, modelName, int(resID), vals)
return orm.UpdateRecordByID(ctx, modelName, int(resID), vals)

case strings.HasPrefix(code, "webhook:"):
url := strings.TrimSpace(strings.TrimPrefix(code, "webhook:"))
Expand Down
4 changes: 4 additions & 0 deletions addons/automation/testexports.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"sumeru/core/event"
)

func ValidateWebhookURLForTest(raw string) error {
return validateWebhookURL(raw)
}

func ExecuteServerActionForTest(ctx context.Context, row map[string]interface{}, ev event.Event) error {
return executeServerAction(ctx, row, ev)
}
Expand Down
84 changes: 80 additions & 4 deletions addons/automation/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,31 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"

"sumeru/core/applog"
"sumeru/core/event"
"sumeru/core/metrics"
)

func dispatchWebhook(ctx context.Context, url string, ev event.Event) error {
func dispatchWebhook(ctx context.Context, rawURL string, ev event.Event) error {
if err := validateWebhookURL(rawURL); err != nil {
metrics.Inc("sumeru_webhook_blocked_total")
applog.Warn(ctx, applog.Event{
Message: "webhook URL rejected",
Component: "automation",
Operation: "webhook",
Status: "blocked",
Context: map[string]interface{}{"url": rawURL},
Err: err,
})
return err
}
body, err := json.Marshal(map[string]interface{}{
"event": ev.Name,
"actor": ev.Actor,
Expand All @@ -20,12 +37,20 @@ func dispatchWebhook(ctx context.Context, url string, ev event.Event) error {
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("webhook redirect limit exceeded")
}
return validateWebhookURL(req.URL.String())
},
}
resp, err := client.Do(req)
if err != nil {
return err
Expand All @@ -37,8 +62,59 @@ func dispatchWebhook(ctx context.Context, url string, ev event.Event) error {
Component: "automation",
Operation: "webhook",
Status: "failed",
Context: map[string]interface{}{"url": url, "status": resp.StatusCode},
Context: map[string]interface{}{"url": rawURL, "status": resp.StatusCode},
})
}
return nil
}

func validateWebhookURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("empty webhook url")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid webhook url: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "https" && scheme != "http" {
return fmt.Errorf("webhook scheme %q not allowed", u.Scheme)
}
host := strings.TrimSpace(u.Hostname())
if host == "" {
return fmt.Errorf("webhook host required")
}
lowerHost := strings.ToLower(host)
if lowerHost == "localhost" || strings.HasSuffix(lowerHost, ".localhost") || lowerHost == "metadata.google.internal" {
return fmt.Errorf("webhook host not allowed")
}
if ip := net.ParseIP(host); ip != nil {
if blockedWebhookIP(ip) {
return fmt.Errorf("webhook IP not allowed")
}
return nil
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("webhook host lookup: %w", err)
}
if len(ips) == 0 {
return fmt.Errorf("webhook host resolved to no addresses")
}
for _, ip := range ips {
if blockedWebhookIP(ip) {
return fmt.Errorf("webhook host resolves to blocked address")
}
}
return nil
}

func blockedWebhookIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast()
}
29 changes: 29 additions & 0 deletions core/engine/render/html_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,35 @@ func SafeImageSrc(src string) bool {
strings.HasPrefix(src, "/"))
}

// SafeIframeURL reports whether src is safe for an iframe (https or site-relative path).
// Rejects javascript:, data:, file:, and protocol-relative URLs.
func SafeIframeURL(src string) bool {
src = strings.TrimSpace(src)
if src == "" {
return false
}
lower := strings.ToLower(src)
if strings.HasPrefix(lower, "javascript:") ||
strings.HasPrefix(lower, "data:") ||
strings.HasPrefix(lower, "file:") ||
strings.HasPrefix(lower, "vbscript:") ||
strings.HasPrefix(src, "//") {
return false
}
if strings.HasPrefix(src, "/") {
return true
}
return strings.HasPrefix(lower, "https://")
}

// SafeIframeURLAllowHTTP is SafeIframeURL plus http:// absolute URLs (dev-only callers).
func SafeIframeURLAllowHTTP(src string) bool {
if SafeIframeURL(src) {
return true
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(src)), "http://")
}

// FieldDisplayLabel returns the column/field label from XML string attr or a humanized field name.
func FieldDisplayLabel(field parser.Field) string {
if label := strings.TrimSpace(field.Label); label != "" {
Expand Down
14 changes: 13 additions & 1 deletion core/engine/render/menu_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,29 @@ func ModuleIconServePath(moduleName, iconRel string) string {
if a == nil || a.Path == "" {
return ""
}
root := filepath.Clean(a.Path)
candidates := []string{}
if iconRel = strings.TrimSpace(iconRel); iconRel != "" {
candidates = append(candidates, iconRel)
}
candidates = append(candidates, "static/icon.png")
for _, rel := range candidates {
rel = strings.TrimSpace(rel)
if rel == "" || strings.Contains(rel, `\`) || strings.Contains(rel, "..") || strings.HasPrefix(rel, "/") {
continue
}
if filepath.IsAbs(rel) || (len(rel) >= 2 && rel[1] == ':') {
continue
}
rel = filepath.Clean(rel)
if rel == "." || strings.HasPrefix(rel, "..") {
continue
}
full := filepath.Join(a.Path, rel)
full := filepath.Join(root, rel)
relToRoot, err := filepath.Rel(root, full)
if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) {
continue
}
if fi, err := os.Stat(full); err == nil && !fi.IsDir() {
return full
}
Expand Down
36 changes: 2 additions & 34 deletions core/orm/config_param.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,14 @@ import (
"strings"
)

const configParamModel = "sys.config.parameter"

// GetConfigParam returns the value for key, or defaultVal when missing or empty.
func GetConfigParam(ctx context.Context, key, defaultVal string) string {
key = strings.TrimSpace(key)
if key == "" {
return defaultVal
}
row, err := SearchOne(ctx, configParamModel, map[string]interface{}{"key": key})
if err != nil {
return defaultVal
}
val := strings.TrimSpace(AsString(row["value"]))
if val == "" {
return defaultVal
}
return val
return GetConfig(ctx, key, defaultVal)
}

// SetConfigParam upserts a sys.config.parameter row by key.
func SetConfigParam(ctx context.Context, key, value string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
bypass := ContextWithBypass(ctx, true)
existing, err := Search(bypass, configParamModel, [][]interface{}{{"key", "=", key}})
if err != nil {
return err
}
if len(existing) > 0 {
id, _ := CoerceInt64(existing[0]["id"])
return UpdateRecordByID(bypass, configParamModel, int(id), map[string]interface{}{"value": value})
}
m, ok := Registry[configParamModel]
if !ok {
return nil
}
_, err = Create(bypass, m, map[string]interface{}{"key": key, "value": value})
return err
return SetConfig(ctx, key, value)
}

// ConfigParamBool parses a config parameter as boolean (true/1/t/yes).
Expand Down
37 changes: 37 additions & 0 deletions core/orm/crud_coerce.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,47 @@
package orm

import (
"encoding/json"
"fmt"
"strconv"
"strings"
)

// CoerceFloat64 reads numeric values (JSON, DB drivers) into float64.
func CoerceFloat64(v interface{}) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case float32:
return float64(t), true
case int:
return float64(t), true
case int64:
return float64(t), true
case int32:
return float64(t), true
case json.Number:
f, err := t.Float64()
return f, err == nil
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
case []byte:
s := strings.TrimSpace(string(t))
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
default:
return 0, false
}
}

// CoerceInt64 reads numeric values from database drivers into int64.
func CoerceInt64(v interface{}) (int64, bool) {
switch t := v.(type) {
Expand Down
19 changes: 1 addition & 18 deletions core/orm/domain_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)

Expand Down Expand Up @@ -233,21 +232,5 @@ func AsBool(v interface{}) bool {
}

func toFloat64(v interface{}) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case float32:
return float64(t), true
case int:
return float64(t), true
case int64:
return float64(t), true
default:
s := strings.TrimSpace(AsString(v))
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
}
return CoerceFloat64(v)
}
10 changes: 5 additions & 5 deletions core/orm/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func SyncModels() error {
} else if !ShouldMaterializeModel(name, installed) {
continue
}
if err := createTable(model); err != nil {
if err := createTable(ctx, model); err != nil {
return err
}
}
Expand Down Expand Up @@ -108,7 +108,7 @@ func ColumnTypeSQL(f FieldDefinition) (string, bool) {
}
}

func createTable(model Model) error {
func createTable(ctx context.Context, model Model) error {
physical, err := ModelToTableName(model.ModelName())
if err != nil {
return err
Expand All @@ -117,7 +117,7 @@ func createTable(model Model) error {
if err != nil {
return err
}
exists, err := tableExists(physical)
exists, err := tableExists(ctx, physical)
if err != nil {
return err
}
Expand Down Expand Up @@ -156,8 +156,8 @@ func createTable(model Model) error {
}

query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s);", tableName, strings.Join(columns, ", "))
if _, err := DB.Exec(query); err != nil {
if _, err := DB.ExecContext(ctx, query); err != nil {
return err
}
return ensureModelIndexes(schemaTable{ModelName: model.ModelName(), TableName: physical, QuotedTable: tableName, Model: model})
return ensureModelIndexes(ctx, schemaTable{ModelName: model.ModelName(), TableName: physical, QuotedTable: tableName, Model: model})
}
2 changes: 1 addition & 1 deletion core/orm/schema_fk.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func ensureForeignKeys(ctx context.Context, tbl schemaTable) error {
`ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (id) ON DELETE %s NOT VALID`,
tbl.QuotedTable, quoteIdent(constraintName), colQuoted, targetQuoted, onDelete,
)
if _, err := DB.Exec(q); err != nil {
if _, err := DB.ExecContext(ctx, q); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
continue
}
Expand Down
Loading