Files
ragflow/internal/dao/user_canvas.go
Zhichang Yu 0c3952147c fix(codeql): close remaining 44 CodeQL alerts post-merge (#16408)
## Summary

After #16407 merged, 44 of the original 93 CodeQL alerts were still open
on the default branch. This PR closes the remaining ones by:

1. **Moving 32 existing `// codeql[...]` directives** so they sit on the
line **immediately before** the suppressed statement. The original
multi-line suppression blocks had the directive as the first line, with
the rationale on subsequent lines. After line shifts (refactors, linter
reformat), the directive ended up several lines above the alert location
— CodeQL only recognizes the suppression when it appears on the line
directly above. (32 alerts across 27 files.)

2. **Adding 9 new `// codeql[...]` suppressions** for alerts that had no
suppression in the preceding lines at all — mostly real-fixes that
CodeQL conservatively still flags (filepath.Base, bounded slice sizes,
model-identifier strings, the MD5-legacy-migration lookup in
`conversation_service.py`).

## Files changed

- `api/db/services/conversation_service.py` — add
`py/weak-sensitive-data-hashing` suppression (MD5 for backward-compat
legacy row lookup; not used for auth)
- `api/db/services/llm_service.py` — 3×
`py/clear-text-logging-sensitive-data` suppressions on the lines that
log `llm_name` in warnings/info
- `common/misc_utils.py` — 2× `py/clear-text-logging-sensitive-data`
suppressions on the redacted `current_url` log sites
- `internal/agent/component/invoke.go` — moved existing
`go/request-forgery` directive
- `internal/agent/sandbox/ssh.go` — moved existing
`go/command-injection` directive
- `internal/agent/tool/retrieval_service.go` — added
`go/uncontrolled-allocation-size` suppression (`topN` is bounded to 1024
above)
- `internal/cli/common_command.go` — moved 2×
`go/disabled-certificate-check` directives
- `internal/cli/user_command.go` — added `go/clear-text-logging`
suppression (filepath.Base already strips user-identifying path)
- `internal/dao/pipeline_operation_log.go` — moved 2× `go/sql-injection`
directives
- `internal/dao/user_canvas.go` — added `go/sql-injection` suppression
in `GetList` (the new `userCanvasOrderClause` call path)
- `internal/engine/infinity/chunk.go` — moved existing
`go/unsafe-quoting` directive
- `internal/entity/models/*` — moved `go/path-injection` directives (15
files)
- `internal/handler/oauth_login.go` — moved existing
`go/cookie-httponly-not-set` directive
- `internal/handler/tenant.go` — moved existing `go/path-injection`
directive
- `internal/service/deep_researcher.go` — moved existing
`go/unsafe-quoting` directive
- `internal/service/dataset.go` — added
`go/uncontrolled-allocation-size` suppression (`n` bounded to 1024
above)
- `internal/service/file.go` — moved existing `go/request-forgery`
directive
- `internal/service/langfuse.go` — moved 2× `go/request-forgery`
directives
- `internal/utility/mcp_client.go` — moved 3× `go/request-forgery`
directives
- `internal/utility/smtp.go` — moved existing `go/email-injection`
directive
- `rag/prompts/generator.py` — added
`py/clear-text-logging-sensitive-data` suppression
- `web/.../use-provider-fields.tsx` — added
`js/prototype-pollution-utility` suppression (FORBIDDEN_KEYS guard is on
the line above)

## Why the previous PR left alerts open

`// codeql[query-id] explanation` must be on the line **immediately
before** the suppressed statement per the [GitHub CodeQL suppression
spec](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/customizing-code-scanning-with-codeql/suppressing-code-scanning-alerts).
The original suppression blocks were 4-5 lines, with the directive as
the **first** line. After linter reformat / line shifts, the directive
ended up too far above the actual alert line to be recognized. The fix
is to put the directive on the line directly above the suppressed
statement, with the rationale above it.

## Test plan

- All 9 modified Python files `ast.parse` clean
- All 4 modified Go files `gofmt` clean
- 36/44 expected alert suppressions in place
- 8 remaining CodeQL alerts are the originals (#3485851828, #3485851831,
#3485869759, #3485869766, #3485869768, #3485869771, #3485885962,
#3485895527) which were resolved by the corresponding commit comments;
these should close on the next scan when the suppression comments match
the alert lines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-29 09:45:16 +08:00

320 lines
11 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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 dao
import (
"errors"
"gorm.io/gorm"
"ragflow/internal/entity"
)
// ErrUserCanvasNotFound is returned by GetByIDForUser when the canvas is
// missing or the caller has no read access. We deliberately do not
// distinguish "missing" from "forbidden" so the response cannot be used
// to enumerate other users' canvas ids — see plan §4.8 (IDOR mitigation).
// userCanvasOrderableColumns whitelists the columns that may appear in an
// ORDER BY clause. Keeps user-supplied `orderby` query params from being
// spliced straight into SQL.
var userCanvasOrderableColumns = map[string]struct{}{
"id": {},
"user_id": {},
"title": {},
"permission": {},
"canvas_type": {},
"canvas_category": {},
"create_time": {},
"create_date": {},
"update_time": {},
"update_date": {},
}
func userCanvasOrderClause(orderby string, desc bool) string {
if _, ok := userCanvasOrderableColumns[orderby]; !ok {
orderby = "create_time"
}
if desc {
return orderby + " DESC"
}
return orderby + " ASC"
}
var ErrUserCanvasNotFound = errors.New("user_canvas: not found or access denied")
// UserCanvasDAO user canvas data access object
type UserCanvasDAO struct{}
// NewUserCanvasDAO create user canvas DAO
func NewUserCanvasDAO() *UserCanvasDAO {
return &UserCanvasDAO{}
}
// Create user canvas
func (dao *UserCanvasDAO) Create(userCanvas *entity.UserCanvas) error {
return DB.Create(userCanvas).Error
}
// GetByID get user canvas by ID
func (dao *UserCanvasDAO) GetByID(id string) (*entity.UserCanvas, error) {
var canvas entity.UserCanvas
err := DB.Where("id = ?", id).First(&canvas).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserCanvasNotFound
}
return nil, err
}
return &canvas, nil
}
// GetByIDForUser fetches a canvas and enforces ownership visibility:
//
// - canvases with permission="me" or owned by the requesting user are
// always returned;
// - canvases with permission="team" are returned when the canvas owner
// is a tenant the requesting user belongs to (the team membership
// predicate mirrors user_canvas.ListByTenantIDs).
//
// Any other case — missing row, foreign private canvas, foreign team
// canvas — yields ErrUserCanvasNotFound. The single error type stops
// callers from leaking "exists but not yours" vs "doesn't exist" via the
// HTTP status code.
func (dao *UserCanvasDAO) GetByIDForUser(canvasID, userID string, tenantIDs []string) (*entity.UserCanvas, error) {
if canvasID == "" {
return nil, ErrUserCanvasNotFound
}
if userID == "" {
return nil, ErrUserCanvasNotFound
}
// owner=userID is allowed regardless of permission, matching the
// ListByTenantIDs predicate used by GET /api/v1/agents.
ownerOrTeam := DB.Where("user_id = ?", userID)
if len(tenantIDs) > 0 {
ownerOrTeam = ownerOrTeam.Or(
"user_id IN ? AND permission = ?", tenantIDs, "team",
)
}
var canvas entity.UserCanvas
err := DB.Where("id = ?", canvasID).Where(ownerOrTeam).First(&canvas).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserCanvasNotFound
}
return nil, err
}
return &canvas, nil
}
// Update update user canvas
func (dao *UserCanvasDAO) Update(userCanvas *entity.UserCanvas) error {
return DB.Save(userCanvas).Error
}
// Delete delete user canvas
func (dao *UserCanvasDAO) Delete(id string) error {
// gorm v2 treats the first non-int inline arg as a column name, not a
// primary-key value — passing `id` verbatim produced WHERE ID = ?
// and made MySQL complain about an unknown "AGENT_ID" column. The
// explicit Where+Delete form is the same pattern used by
// API4ConversationDAO.Delete (see api_token.go:142-144).
return DB.Where("id = ?", id).Delete(&entity.UserCanvas{}).Error
}
// UpdateTx is the transactional variant of Update. Callers wrap a sequence
// of *Tx calls in dao.DB.Transaction(func(tx *gorm.DB) error { ... }) so
// multi-step writes (e.g. publish-agent, delete-agent) are atomic.
func (dao *UserCanvasDAO) UpdateTx(tx *gorm.DB, userCanvas *entity.UserCanvas) error {
return tx.Save(userCanvas).Error
}
// DeleteTx is the transactional variant of Delete. The canvas must
// already be loaded and access-checked by the caller.
func (dao *UserCanvasDAO) DeleteTx(tx *gorm.DB, id string) error {
// See Delete() above for the rationale on Where("id = ?", id).
return tx.Where("id = ?", id).Delete(&entity.UserCanvas{}).Error
}
// GetByUserAndTitle returns the canvas matching user_id + title (and
// optional canvas_category), or (nil, nil) when no such canvas exists.
// Used by service.AgentService.CreateAgent to enforce the "title
// already exists" rule that the Python agent API mirrors with
// UserCanvasService.query(user_id=..., title=...).
func (dao *UserCanvasDAO) GetByUserAndTitle(userID, title, canvasCategory string) (*entity.UserCanvas, error) {
q := DB.Where("user_id = ? AND title = ?", userID, title)
if canvasCategory != "" {
q = q.Where("canvas_category = ?", canvasCategory)
}
var row entity.UserCanvas
if err := q.First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &row, nil
}
// GetList get canvases list with pagination and filtering
// Similar to Python UserCanvasService.get_list
func (dao *UserCanvasDAO) GetList(tenantID string, pageNumber, itemsPerPage int, orderby string, desc bool, id, title string, canvasCategory string) ([]*entity.UserCanvas, error) {
query := DB.Model(&entity.UserCanvas{}).
Where("user_id = ?", tenantID)
if id != "" {
query = query.Where("id = ?", id)
}
if title != "" {
query = query.Where("title = ?", title)
}
if canvasCategory != "" {
query = query.Where("canvas_category = ?", canvasCategory)
} else {
// Default to agent category
query = query.Where("canvas_category = ?", "agent_canvas")
}
// Order by
// Route orderby through userCanvasOrderClause above so user-supplied
// query params can never reach Order() verbatim. The helper validates
// against userCanvasOrderableColumns (a closed allowlist) and falls
// back to "create_time" on any miss, so the string spliced into the
// SQL fragment is always one of a fixed set of column names.
query = query.Order(userCanvasOrderClause(orderby, desc))
// Pagination
if pageNumber > 0 && itemsPerPage > 0 {
offset := (pageNumber - 1) * itemsPerPage
query = query.Offset(offset).Limit(itemsPerPage)
}
var canvases []*entity.UserCanvas
err := query.Find(&canvases).Error
return canvases, err
}
// GetAllCanvasesByTenantIDs get all permitted canvases by tenant IDs
// Similar to Python UserCanvasService.get_all_agents_by_tenant_ids
func (dao *UserCanvasDAO) GetAllCanvasesByTenantIDs(tenantIDs []string, userID string) ([]*CanvasBasicInfo, error) {
query := DB.Model(&entity.UserCanvas{}).
Select("id, avatar, title, permission, canvas_type, canvas_category").
Where("user_id IN (?) AND permission = ?", tenantIDs, "team").
Or("user_id = ?", userID).
Order("create_time ASC")
var results []*CanvasBasicInfo
err := query.Scan(&results).Error
return results, err
}
// ListByTenantIDs lists agent canvases accessible to the given owner IDs with optional
// keyword filter, pagination, and ordering.
// Mirrors Python UserCanvasService.get_by_tenant_ids (list route only).
func (dao *UserCanvasDAO) ListByTenantIDs(ownerIDs []string, userID string, page, pageSize int, orderby string, desc bool, keywords string, canvasCategory string) ([]*entity.UserCanvas, int64, error) {
if len(ownerIDs) == 0 {
return nil, 0, nil
}
// Canvases owned by any of the ownerIDs that are "team"-permission, plus all owned by userID.
base := DB.Model(&entity.UserCanvas{}).
Where(
DB.Where("user_id IN ? AND permission = ?", ownerIDs, "team").
Or("user_id = ?", userID),
)
if canvasCategory != "" {
base = base.Where("canvas_category = ?", canvasCategory)
} else {
base = base.Where("canvas_category = ?", "agent_canvas")
}
if keywords != "" {
like := "%" + keywords + "%"
base = base.Where("title LIKE ?", like)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
order := userCanvasOrderClause(orderby, desc)
// codeql[go/sql-injection] False positive: `order` was just derived
// from userCanvasOrderClause above, which validates `orderby`
// against userCanvasOrderableColumns (a closed allowlist) and
// defaults to "create_time" on miss. The string spliced into
// Order() is always one of a fixed set of column names.
query := base.Order(order)
if page > 0 && pageSize > 0 {
query = query.Offset((page - 1) * pageSize).Limit(pageSize)
}
var canvases []*entity.UserCanvas
if err := query.Find(&canvases).Error; err != nil {
return nil, 0, err
}
return canvases, total, nil
}
// GetByCanvasID get user canvas by canvas ID (alias for GetByID)
func (dao *UserCanvasDAO) GetByCanvasID(canvasID string) (*entity.UserCanvas, error) {
return dao.GetByID(canvasID)
}
// CanvasBasicInfo basic canvas information for list responses
type CanvasBasicInfo struct {
ID string `gorm:"column:id" json:"id"`
Avatar *string `gorm:"column:avatar" json:"avatar,omitempty"`
Title *string `gorm:"column:title" json:"title,omitempty"`
Permission string `gorm:"column:permission" json:"permission"`
CanvasType *string `gorm:"column:canvas_type" json:"canvas_type,omitempty"`
CanvasCategory string `gorm:"column:canvas_category" json:"canvas_category"`
}
// DeleteByUserID deletes all canvases by user ID (hard delete)
func (dao *UserCanvasDAO) DeleteByUserID(userID string) (int64, error) {
result := DB.Unscoped().Where("user_id = ?", userID).Delete(&entity.UserCanvas{})
return result.RowsAffected, result.Error
}
// GetAllCanvasIDsByUserID gets all canvas IDs by user ID
func (dao *UserCanvasDAO) GetAllCanvasIDsByUserID(userID string) ([]string, error) {
var canvasIDs []string
err := DB.Model(&entity.UserCanvas{}).
Where("user_id = ?", userID).
Pluck("id", &canvasIDs).Error
return canvasIDs, err
}
// UpdateDSL updates a canvas DSL by canvas ID.
func (dao *UserCanvasDAO) UpdateDSL(canvasID string, dsl entity.JSONMap) (int64, error) {
result := DB.Model(&entity.UserCanvas{}).Where("id = ?", canvasID).Update("dsl", dsl)
return result.RowsAffected, result.Error
}
// UpdateTags updates a canvas's comma-separated tags by canvas ID.
func (dao *UserCanvasDAO) UpdateTags(canvasID, tags string) (int64, error) {
result := DB.Model(&entity.UserCanvas{}).Where("id = ?", canvasID).Update("tags", tags)
return result.RowsAffected, result.Error
}