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: 2 additions & 0 deletions backend/modules/dashboards/domain/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ type Dashboard struct {
Name string `gorm:"column:name;size:100;not null;uniqueIndex" json:"name"`
Description string `gorm:"column:description;size:255" json:"description"`
Config string `gorm:"column:config" json:"config"`
// RefreshTime is the auto-refresh interval in seconds. 0 disables auto-refresh.
RefreshTime int64 `gorm:"column:refresh_time;not null;default:0" json:"refreshTime"`
SystemOwner bool `gorm:"column:system_owner" json:"systemOwner"`
CreatedDate time.Time `gorm:"column:created_date" json:"createdDate"`
ModifiedDate time.Time `gorm:"column:modified_date" json:"modifiedDate"`
Expand Down
1 change: 1 addition & 0 deletions backend/modules/dashboards/domain/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ var (
ErrIDRequired = errors.New("id is required for update")
ErrNameRequired = errors.New("name is required")
ErrSQLQueryRequired = errors.New("sqlQuery is required")
ErrInvalidSQL = errors.New("invalid sqlQuery")
)
2 changes: 1 addition & 1 deletion backend/modules/dashboards/handler/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrIDForbidden), errors.Is(err, domain.ErrIDRequired), errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrSQLQueryRequired):
case errors.Is(err, domain.ErrIDForbidden), errors.Is(err, domain.ErrIDRequired), errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrSQLQueryRequired), errors.Is(err, domain.ErrInvalidSQL):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand Down
27 changes: 23 additions & 4 deletions backend/modules/dashboards/usecase/visualization.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package usecase

import (
"context"
"fmt"
"strings"
"time"

"github.com/utmstack/utmstack/backend/modules/dashboards/connectors"
"github.com/utmstack/utmstack/backend/modules/dashboards/domain"
"github.com/utmstack/utmstack/backend/modules/dashboards/dto"
os_usecase "github.com/utmstack/utmstack/backend/modules/opensearch/usecase"
)

type visualizationUsecase struct {
Expand All @@ -25,8 +27,8 @@ func (u *visualizationUsecase) Create(ctx context.Context, v *domain.Visualizati
if strings.TrimSpace(v.Name) == "" {
return nil, domain.ErrNameRequired
}
if strings.TrimSpace(v.SQLQuery) == "" {
return nil, domain.ErrSQLQueryRequired
if err := sanitizeVisualizationSQL(v); err != nil {
return nil, err
}
now := time.Now().UTC()
v.CreatedDate = now
Expand All @@ -48,8 +50,8 @@ func (u *visualizationUsecase) Update(ctx context.Context, v *domain.Visualizati
if existing == nil {
return nil, domain.ErrNotFound
}
if strings.TrimSpace(v.SQLQuery) == "" {
return nil, domain.ErrSQLQueryRequired
if err := sanitizeVisualizationSQL(v); err != nil {
return nil, err
}
v.CreatedDate = existing.CreatedDate
v.SystemOwner = existing.SystemOwner
Expand All @@ -71,3 +73,20 @@ func (u *visualizationUsecase) List(ctx context.Context, f dto.VisualizationFilt
func (u *visualizationUsecase) Delete(ctx context.Context, id uint64) error {
return u.repo.Delete(ctx, id)
}

// sanitizeVisualizationSQL trims and validates a visualization's SQL query
// through the same guard used at query-execution time (opensearch.ValidateSQL:
// SELECT-only, no comments, no DML/DDL keywords, balanced quotes/parens, only
// whitelisted aggregate functions). Placeholders like {{timeFilter}} and
// {{dashboardFilters}} pass through unchanged. Enforcing here on save prevents
// dangerous queries from ever reaching the database.
func sanitizeVisualizationSQL(v *domain.Visualization) error {
v.SQLQuery = strings.TrimSpace(v.SQLQuery)
if v.SQLQuery == "" {
return domain.ErrSQLQueryRequired
}
if err := os_usecase.ValidateSQL(v.SQLQuery); err != nil {
return fmt.Errorf("%w: %s", domain.ErrInvalidSQL, err.Error())
}
return nil
}
70 changes: 70 additions & 0 deletions backend/modules/dashboards/usecase/visualization_sanitize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package usecase

import (
"errors"
"testing"

"github.com/utmstack/utmstack/backend/modules/dashboards/domain"
)

func TestSanitizeVisualizationSQL(t *testing.T) {
cases := []struct {
name string
sql string
wantErr error // sentinel expected via errors.Is (nil means pass)
}{
{
name: "valid templated SELECT",
sql: `SELECT bucket, count(*) AS y FROM logs
WHERE {{dashboardFilters}}{{timeFilter}}
GROUP BY bucket
ORDER BY y DESC
LIMIT 100`,
},
{
name: "empty is rejected as required",
sql: " ",
wantErr: domain.ErrSQLQueryRequired,
},
{
name: "non-SELECT rejected",
sql: "UPDATE logs SET user='x' WHERE 1=1",
wantErr: domain.ErrInvalidSQL,
},
{
name: "forbidden keyword rejected",
sql: "SELECT * FROM logs; DROP TABLE users",
wantErr: domain.ErrInvalidSQL,
},
{
name: "line comment rejected",
sql: "SELECT * FROM logs -- inject",
wantErr: domain.ErrInvalidSQL,
},
{
name: "block comment rejected",
sql: "SELECT /* trick */ * FROM logs",
wantErr: domain.ErrInvalidSQL,
},
{
name: "disallowed function rejected",
sql: "SELECT LOAD_FILE('/etc/passwd') FROM logs",
wantErr: domain.ErrInvalidSQL,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v := &domain.Visualization{SQLQuery: tc.sql}
err := sanitizeVisualizationSQL(v)
if tc.wantErr == nil {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
return
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("expected errors.Is(%v), got %v", tc.wantErr, err)
}
})
}
}
Loading
Loading