Skip to content

feat: Guard search queries against common mistakes #6404

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Mar 2, 2023
127 changes: 2 additions & 125 deletions coderd/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"

"github.com/google/uuid"
Expand All @@ -22,6 +20,7 @@ import (
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/searchquery"
"github.com/coder/coder/codersdk"
)

Expand Down Expand Up @@ -49,7 +48,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
}

queryStr := r.URL.Query().Get("q")
filter, errs := auditSearchQuery(queryStr)
filter, errs := searchquery.AuditLogs(queryStr)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid audit search query.",
Expand Down Expand Up @@ -373,125 +372,3 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
return ""
}
}

// auditSearchQuery takes a query string and returns the auditLog filter.
// It also can return the list of validation errors to return to the api.
func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []codersdk.ValidationError) {
searchParams := make(url.Values)
if query == "" {
// No filter
return database.GetAuditLogsOffsetParams{}, nil
}
query = strings.ToLower(query)
// Because we do this in 2 passes, we want to maintain quotes on the first
// pass.Further splitting occurs on the second pass and quotes will be
// dropped.
elements := splitQueryParameterByDelimiter(query, ' ', true)
for _, element := range elements {
parts := splitQueryParameterByDelimiter(element, ':', false)
switch len(parts) {
case 1:
// No key:value pair.
searchParams.Set("resource_type", parts[0])
case 2:
searchParams.Set(parts[0], parts[1])
default:
return database.GetAuditLogsOffsetParams{}, []codersdk.ValidationError{
{Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element)},
}
}
}

// Using the query param parser here just returns consistent errors with
// other parsing.
parser := httpapi.NewQueryParamParser()
const layout = "2006-01-02"

var (
dateFromString = parser.String(searchParams, "", "date_from")
dateToString = parser.String(searchParams, "", "date_to")
parsedDateFrom, _ = time.Parse(layout, dateFromString)
parsedDateTo, _ = time.Parse(layout, dateToString)
)

if dateToString != "" {
parsedDateTo = parsedDateTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second) // parsedDateTo goes to 23:59
}

if dateToString != "" && parsedDateTo.Before(parsedDateFrom) {
return database.GetAuditLogsOffsetParams{}, []codersdk.ValidationError{
{Field: "q", Detail: fmt.Sprintf("DateTo value %q cannot be before than DateFrom", parsedDateTo)},
}
}

filter := database.GetAuditLogsOffsetParams{
ResourceType: resourceTypeFromString(parser.String(searchParams, "", "resource_type")),
ResourceID: parser.UUID(searchParams, uuid.Nil, "resource_id"),
Action: actionFromString(parser.String(searchParams, "", "action")),
Username: parser.String(searchParams, "", "username"),
Email: parser.String(searchParams, "", "email"),
DateFrom: parsedDateFrom,
DateTo: parsedDateTo,
BuildReason: buildReasonFromString(parser.String(searchParams, "", "build_reason")),
}

return filter, parser.Errors
}

func resourceTypeFromString(resourceTypeString string) string {
switch codersdk.ResourceType(resourceTypeString) {
case codersdk.ResourceTypeTemplate:
return resourceTypeString
case codersdk.ResourceTypeTemplateVersion:
return resourceTypeString
case codersdk.ResourceTypeUser:
return resourceTypeString
case codersdk.ResourceTypeWorkspace:
return resourceTypeString
case codersdk.ResourceTypeWorkspaceBuild:
return resourceTypeString
case codersdk.ResourceTypeGitSSHKey:
return resourceTypeString
case codersdk.ResourceTypeAPIKey:
return resourceTypeString
case codersdk.ResourceTypeGroup:
return resourceTypeString
case codersdk.ResourceTypeLicense:
return resourceTypeString
}
return ""
}

func actionFromString(actionString string) string {
switch codersdk.AuditAction(actionString) {
case codersdk.AuditActionCreate:
return actionString
case codersdk.AuditActionWrite:
return actionString
case codersdk.AuditActionDelete:
return actionString
case codersdk.AuditActionStart:
return actionString
case codersdk.AuditActionStop:
return actionString
case codersdk.AuditActionLogin:
return actionString
case codersdk.AuditActionLogout:
return actionString
default:
}
return ""
}

func buildReasonFromString(buildReasonString string) string {
switch codersdk.BuildReason(buildReasonString) {
case codersdk.BuildReasonInitiator:
return buildReasonString
case codersdk.BuildReasonAutostart:
return buildReasonString
case codersdk.BuildReasonAutostop:
return buildReasonString
default:
}
return ""
}
31 changes: 18 additions & 13 deletions coderd/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ func TestAuditLogsFilter(t *testing.T) {
Name string
SearchQuery string
ExpectedResult int
ExpectedError bool
}{
{
Name: "FilterByCreateAction",
Expand Down Expand Up @@ -188,19 +189,19 @@ func TestAuditLogsFilter(t *testing.T) {
ExpectedResult: 2,
},
{
Name: "FilterInvalidSingleValue",
SearchQuery: "invalid",
ExpectedResult: 5,
Name: "FilterInvalidSingleValue",
SearchQuery: "invalid",
ExpectedError: true,
},
{
Name: "FilterWithInvalidResourceType",
SearchQuery: "resource_type:invalid",
ExpectedResult: 5,
Name: "FilterWithInvalidResourceType",
SearchQuery: "resource_type:invalid",
ExpectedError: true,
},
{
Name: "FilterWithInvalidAction",
SearchQuery: "action:invalid",
ExpectedResult: 5,
Name: "FilterWithInvalidAction",
SearchQuery: "action:invalid",
ExpectedError: true,
},
{
Name: "FilterOnCreateSingleDay",
Expand Down Expand Up @@ -229,7 +230,7 @@ func TestAuditLogsFilter(t *testing.T) {
},
{
Name: "FilterOnWorkspaceBuildStartByInitiator",
SearchQuery: "resource_type:workspace_build action:start build_reason:start",
SearchQuery: "resource_type:workspace_build action:start build_reason:initiator",
ExpectedResult: 1,
},
}
Expand All @@ -245,9 +246,13 @@ func TestAuditLogsFilter(t *testing.T) {
Limit: 25,
},
})
require.NoError(t, err, "fetch audit logs")
require.Len(t, auditLogs.AuditLogs, testCase.ExpectedResult, "expected audit logs returned")
require.Equal(t, testCase.ExpectedResult, int(auditLogs.Count), "expected audit log count returned")
if testCase.ExpectedError {
require.Error(t, err, "expected error")
} else {
require.NoError(t, err, "fetch audit logs")
require.Len(t, auditLogs.AuditLogs, testCase.ExpectedResult, "expected audit logs returned")
require.Equal(t, testCase.ExpectedResult, int(auditLogs.Count), "expected audit log count returned")
}
})
}
})
Expand Down
22 changes: 11 additions & 11 deletions coderd/database/dbfake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -966,13 +966,13 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
return nil, xerrors.Errorf("get provisioner job: %w", err)
}

switch arg.Status {
case "pending":
switch database.WorkspaceStatus(arg.Status) {
case database.WorkspaceStatusPending:
if !job.StartedAt.Valid {
continue
}

case "starting":
case database.WorkspaceStatusStarting:
if !job.StartedAt.Valid &&
!job.CanceledAt.Valid &&
job.CompletedAt.Valid &&
Expand All @@ -981,15 +981,15 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
continue
}

case "running":
case database.WorkspaceStatusRunning:
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid ||
build.Transition != database.WorkspaceTransitionStart {
continue
}

case "stopping":
case database.WorkspaceStatusStopping:
if !job.StartedAt.Valid &&
!job.CanceledAt.Valid &&
job.CompletedAt.Valid &&
Expand All @@ -998,31 +998,31 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
continue
}

case "stopped":
case database.WorkspaceStatusStopped:
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid ||
build.Transition != database.WorkspaceTransitionStop {
continue
}

case "failed":
case database.WorkspaceStatusFailed:
if (!job.CanceledAt.Valid && !job.Error.Valid) ||
(!job.CompletedAt.Valid && !job.Error.Valid) {
continue
}

case "canceling":
case database.WorkspaceStatusCanceling:
if !job.CanceledAt.Valid && job.CompletedAt.Valid {
continue
}

case "canceled":
case database.WorkspaceStatusCanceled:
if !job.CanceledAt.Valid && !job.CompletedAt.Valid {
continue
}

case "deleted":
case database.WorkspaceStatusDeleted:
if !job.StartedAt.Valid &&
job.CanceledAt.Valid &&
!job.CompletedAt.Valid &&
Expand All @@ -1031,7 +1031,7 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
continue
}

case "deleting":
case database.WorkspaceStatusDeleting:
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid &&
Expand Down
27 changes: 27 additions & 0 deletions coderd/database/modelmethods.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ import (
"github.com/coder/coder/coderd/rbac"
)

type WorkspaceStatus string

const (
WorkspaceStatusPending WorkspaceStatus = "pending"
WorkspaceStatusStarting WorkspaceStatus = "starting"
WorkspaceStatusRunning WorkspaceStatus = "running"
WorkspaceStatusStopping WorkspaceStatus = "stopping"
WorkspaceStatusStopped WorkspaceStatus = "stopped"
WorkspaceStatusFailed WorkspaceStatus = "failed"
WorkspaceStatusCanceling WorkspaceStatus = "canceling"
WorkspaceStatusCanceled WorkspaceStatus = "canceled"
WorkspaceStatusDeleting WorkspaceStatus = "deleting"
WorkspaceStatusDeleted WorkspaceStatus = "deleted"
)

func (s WorkspaceStatus) Valid() bool {
switch s {
case WorkspaceStatusPending, WorkspaceStatusStarting, WorkspaceStatusRunning,
WorkspaceStatusStopping, WorkspaceStatusStopped, WorkspaceStatusFailed,
WorkspaceStatusCanceling, WorkspaceStatusCanceled, WorkspaceStatusDeleting,
WorkspaceStatusDeleted:
return true
default:
return false
}
}

type AuditableGroup struct {
Group
Members []GroupMember `json:"members"`
Expand Down
Loading