diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index e05b1101c2..ebf1f52538 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -220,7 +220,7 @@ func applyCustomFieldCondition(sess *xorm.Session, opts *IssuesOptions) { // custom_field_value row for every specified field (AND semantics). for fieldID, value := range opts.CustomFieldFilters { subQuery := builder.Select("entity_id").From("custom_field_value").Where( - builder.Eq{"field_id": fieldID, "value": value}, + builder.Eq{"field_id": fieldID, "value": value, "entity_type": "issue"}, ) sess.And(builder.In("issue.id", subQuery)) } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 80f3abd8f4..4fe5f7709e 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -289,16 +289,9 @@ func SearchIssues(ctx *context.APIContext) { } } - // Parse custom field filters from cf_{fieldID}={value} query params. - cfFilters := make(map[int64]string) - for key, values := range ctx.Req.URL.Query() { - if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { - if fieldID, parseErr := strconv.ParseInt(after, 10, 64); parseErr == nil { - cfFilters[fieldID] = values[0] - } - } - } - if len(cfFilters) > 0 { + if cfFilters, cfErr := parseAPICustomFieldFilters(ctx); cfErr != nil { + return + } else if len(cfFilters) > 0 { searchOpt.CustomFieldFilters = cfFilters } @@ -530,16 +523,9 @@ func ListIssues(ctx *context.APIContext) { searchOpt.MentionID = optional.Some(mentionedByID) } - // Parse custom field filters from cf_{fieldID}={value} query params. - cfFilters := make(map[int64]string) - for key, values := range ctx.Req.URL.Query() { - if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { - if fieldID, parseErr := strconv.ParseInt(after, 10, 64); parseErr == nil { - cfFilters[fieldID] = values[0] - } - } - } - if len(cfFilters) > 0 { + if cfFilters, cfErr := parseAPICustomFieldFilters(ctx); cfErr != nil { + return + } else if len(cfFilters) > 0 { searchOpt.CustomFieldFilters = cfFilters } @@ -579,6 +565,25 @@ func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 { return user.ID } +// parseAPICustomFieldFilters extracts cf_{fieldID}=value query parameters. +// Returns an error (and writes a 400 response) if a field ID is non-numeric or non-positive. +func parseAPICustomFieldFilters(ctx *context.APIContext) (map[int64]string, error) { + filters := make(map[int64]string) + for key, values := range ctx.Req.URL.Query() { + after, ok := strings.CutPrefix(key, "cf_") + if !ok || len(values) == 0 || values[0] == "" { + continue + } + fieldID, err := strconv.ParseInt(after, 10, 64) + if err != nil || fieldID <= 0 { + ctx.APIError(http.StatusBadRequest, fmt.Sprintf("invalid custom field filter: cf_%s must use a positive numeric field ID", after)) + return nil, fmt.Errorf("invalid cf_ param") + } + filters[fieldID] = values[0] + } + return filters, nil +} + // GetIssue get an issue of a repository func GetIssue(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index a1650878c9..4552ea610a 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -525,19 +525,14 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels) // Parse custom field filters from query params (cf_{fieldID}={value}). - customFieldFilters := make(map[int64]string) - for key, values := range ctx.Req.URL.Query() { - if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { - if fieldID, err := strconv.ParseInt(after, 10, 64); err == nil { - customFieldFilters[fieldID] = values[0] - } - } - } + customFieldFilters := parseCustomFieldQueryParams(ctx.Req.URL.Query()) // Load custom field definitions for the filter UI. + // If this fails, clear filters so users don't get invisible filtering. customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeIssue) if cfErr != nil { log.Error("prepareIssueFilterAndList: GetCustomFieldsByOwner: %v", cfErr) + customFieldFilters = make(map[int64]string) } ctx.Data["CustomFieldDefs"] = customFieldDefs ctx.Data["CustomFieldFilters"] = customFieldFilters @@ -551,7 +546,9 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI for _, f := range customFieldDefs { if f.Options != "" { var opts []string - if err := json.Unmarshal([]byte(f.Options), &opts); err == nil { + if err := json.Unmarshal([]byte(f.Options), &opts); err != nil { + log.Error("prepareIssueFilterAndList: invalid options JSON for field %d (%s): %v", f.ID, f.Name, err) + } else { fieldOptions[f.ID] = opts } } @@ -810,3 +807,17 @@ func Issues(ctx *context.Context) { ctx.HTML(http.StatusOK, tplIssues) } + +// parseCustomFieldQueryParams extracts cf_{fieldID}=value query parameters. +// Non-numeric or non-positive field IDs are silently skipped. +func parseCustomFieldQueryParams(query url.Values) map[int64]string { + filters := make(map[int64]string) + for key, values := range query { + if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { + if fieldID, err := strconv.ParseInt(after, 10, 64); err == nil && fieldID > 0 { + filters[fieldID] = values[0] + } + } + } + return filters +}