1
0
forked from wrenn/wrenn

Fix LIKE pattern injection in user email search

Escape LIKE metacharacters (% and _) in the email prefix before passing
to the SQL query, and enforce the documented '@' requirement to prevent
broad user enumeration. Move search logic out of TeamService into
usersHandler since it is a site-wide lookup, not team-scoped.
This commit is contained in:
2026-03-25 21:53:09 +06:00
parent 88cb24bb86
commit 6eacf0f735
3 changed files with 15 additions and 18 deletions

View File

@ -4,34 +4,38 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/db"
) )
type usersHandler struct { type usersHandler struct {
svc *service.TeamService db *db.Queries
} }
func newUsersHandler(svc *service.TeamService) *usersHandler { func newUsersHandler(db *db.Queries) *usersHandler {
return &usersHandler{svc: svc} return &usersHandler{db: db}
} }
// Search handles GET /v1/users/search?email=<prefix> // Search handles GET /v1/users/search?email=<prefix>
// Returns up to 10 users whose email starts with the given prefix. // Returns up to 10 users whose email starts with the given prefix.
// The prefix must be at least 3 characters long. // The prefix must be at least 3 characters long and contain "@".
func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) { func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
auth.MustFromContext(r.Context()) // ensure authenticated auth.MustFromContext(r.Context()) // ensure authenticated
prefix := strings.TrimSpace(r.URL.Query().Get("email")) prefix := strings.TrimSpace(r.URL.Query().Get("email"))
if len(prefix) < 3 { if len(prefix) < 3 || !strings.Contains(prefix, "@") {
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters") writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'")
return return
} }
results, err := h.svc.SearchUsersByEmailPrefix(r.Context(), prefix) // Escape LIKE metacharacters to prevent pattern injection.
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(prefix)
results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true})
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) writeError(w, http.StatusInternalServerError, "internal", "search failed")
writeError(w, status, code, msg)
return return
} }

View File

@ -61,7 +61,7 @@ func New(
apiKeys := newAPIKeyHandler(apiKeySvc, al) apiKeys := newAPIKeyHandler(apiKeySvc, al)
hostH := newHostHandler(hostSvc, queries, al) hostH := newHostHandler(hostSvc, queries, al)
teamH := newTeamHandler(teamSvc, al) teamH := newTeamHandler(teamSvc, al)
usersH := newUsersHandler(teamSvc) usersH := newUsersHandler(queries)
auditH := newAuditHandler(auditSvc) auditH := newAuditHandler(auditSvc)
statsH := newStatsHandler(statsSvc) statsH := newStatsHandler(statsSvc)
metricsH := newSandboxMetricsHandler(queries, pool) metricsH := newSandboxMetricsHandler(queries, pool)

View File

@ -9,7 +9,6 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
@ -369,12 +368,6 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string
return nil return nil
} }
// SearchUsersByEmailPrefix returns up to 10 users whose email starts with the given prefix.
// The prefix must contain "@" to prevent broad enumeration.
func (s *TeamService) SearchUsersByEmailPrefix(ctx context.Context, prefix string) ([]db.SearchUsersByEmailPrefixRow, error) {
return s.DB.SearchUsersByEmailPrefix(ctx, pgtype.Text{String: prefix, Valid: true})
}
// SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot // SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot
// be disabled — it is a one-way transition. // be disabled — it is a one-way transition.
// Admin-only — the caller must verify admin status before invoking this. // Admin-only — the caller must verify admin status before invoking this.