Skip to content

Commit d19a56a

Browse files
tmablunarclaude
andcommitted
fix(esa-1866): fix SQL injection and apply role to all hosts
- Quote role/schema/table identifiers with pq.QuoteIdentifier - Validate privilege keywords against an allowlist to prevent injection via unquotable SQL keywords - Loop over all HostCredentials instead of a single spec-referenced host - Add customRoleRequeueStrategy with correct log messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 98176c0 commit d19a56a

2 files changed

Lines changed: 92 additions & 63 deletions

File tree

internal/controller/customrole_controller.go

Lines changed: 43 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package controller
1919
import (
2020
"context"
2121
"fmt"
22+
"time"
2223

2324
"github.com/go-logr/logr"
2425
"github.com/google/uuid"
@@ -33,7 +34,6 @@ import (
3334

3435
postgresqlv1alpha1 "go.lunarway.com/postgresql-controller/api/v1alpha1"
3536
ctlerrors "go.lunarway.com/postgresql-controller/pkg/errors"
36-
"go.lunarway.com/postgresql-controller/pkg/kube"
3737
"go.lunarway.com/postgresql-controller/pkg/postgres"
3838
)
3939

@@ -61,7 +61,7 @@ func (r *CustomRoleReconciler) Reconcile(ctx context.Context, req ctrl.Request)
6161
reqLogger = reqLogger.WithValues("requestId", requestID.String())
6262

6363
err = r.reconcile(ctx, reqLogger, req)
64-
return requeueStrategy(reqLogger, err)
64+
return customRoleRequeueStrategy(reqLogger, err)
6565
}
6666

6767
// SetupWithManager sets up the controller with the Manager.
@@ -109,53 +109,55 @@ func (r *CustomRoleReconciler) reconcile(ctx context.Context, reqLogger logr.Log
109109
reqLogger = reqLogger.WithValues("roleName", customRole.Spec.RoleName)
110110
reqLogger.V(1).Info("Reconciling CustomRole resource")
111111

112-
host, adminCredentials, err := r.resolveHostCredentials(ctx, req.Namespace, customRole.Spec.HostCredentials)
113-
if err != nil {
114-
r.persistStatus(ctx, customRole, err)
115-
return fmt.Errorf("resolve host credentials: %w", err)
112+
grants := toPostgresGrants(customRole.Spec.Grants)
113+
114+
for host, creds := range r.HostCredentials {
115+
if err := r.reconcileOnHost(reqLogger, host, creds, customRole.Spec.RoleName, customRole.Spec.GrantRoles, grants); err != nil {
116+
r.persistStatus(ctx, customRole, err)
117+
return fmt.Errorf("reconcile on host %s: %w", host, err)
118+
}
116119
}
117-
reqLogger = reqLogger.WithValues("host", host)
120+
121+
r.persistStatus(ctx, customRole, nil)
122+
return nil
123+
}
124+
125+
func (r *CustomRoleReconciler) reconcileOnHost(log logr.Logger, host string, creds postgres.Credentials, roleName string, grantRoles []string, grants []postgres.CustomRoleGrant) error {
126+
log = log.WithValues("host", host)
118127

119128
// Connect to the postgres maintenance database for server-level operations.
120129
adminConnStr := postgres.ConnectionString{
121130
Host: host,
122131
Database: "postgres",
123-
User: adminCredentials.User,
124-
Password: adminCredentials.Password,
125-
Params: adminCredentials.Params,
132+
User: creds.User,
133+
Password: creds.Password,
134+
Params: creds.Params,
126135
}
127-
adminDB, err := postgres.Connect(reqLogger, adminConnStr)
136+
adminDB, err := postgres.Connect(log, adminConnStr)
128137
if err != nil {
129-
r.persistStatus(ctx, customRole, err)
130138
return fmt.Errorf("connect to host: %w", err)
131139
}
132140
defer adminDB.Close()
133141

134142
// Create the role and apply server-level role grants.
135-
if err := postgres.EnsureCustomRole(reqLogger, adminDB, customRole.Spec.RoleName, customRole.Spec.GrantRoles); err != nil {
136-
r.persistStatus(ctx, customRole, err)
143+
if err := postgres.EnsureCustomRole(log, adminDB, roleName, grantRoles); err != nil {
137144
return fmt.Errorf("ensure role: %w", err)
138145
}
139146

140-
// Apply per-database grants across all user databases.
141-
if len(customRole.Spec.Grants) > 0 {
147+
// Apply per-database grants across all user databases on this host.
148+
if len(grants) > 0 {
142149
databases, err := postgres.UserDatabases(adminDB)
143150
if err != nil {
144-
r.persistStatus(ctx, customRole, err)
145151
return fmt.Errorf("list databases: %w", err)
146152
}
147153

148-
grants := toPostgresGrants(customRole.Spec.Grants)
149-
150154
for _, dbName := range databases {
151-
if err := r.applyGrantsOnDatabase(reqLogger, host, *adminCredentials, customRole.Spec.RoleName, dbName, grants); err != nil {
152-
r.persistStatus(ctx, customRole, err)
155+
if err := r.applyGrantsOnDatabase(log, host, creds, roleName, dbName, grants); err != nil {
153156
return fmt.Errorf("apply grants on database %s: %w", dbName, err)
154157
}
155158
}
156159
}
157160

158-
r.persistStatus(ctx, customRole, nil)
159161
return nil
160162
}
161163

@@ -192,39 +194,6 @@ func (r *CustomRoleReconciler) applyGrantsOnDatabase(log logr.Logger, host strin
192194
return postgres.ApplyDatabaseGrants(log, db, roleName, grants)
193195
}
194196

195-
func (r *CustomRoleReconciler) resolveHostCredentials(ctx context.Context, namespace, hostCredentialsName string) (string, *postgres.Credentials, error) {
196-
var hostCreds postgresqlv1alpha1.PostgreSQLHostCredentials
197-
err := r.Client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: hostCredentialsName}, &hostCreds)
198-
if err != nil {
199-
if apierrors.IsNotFound(err) {
200-
return "", nil, ctlerrors.NewTemporary(fmt.Errorf("PostgreSQLHostCredentials %s/%s not found", namespace, hostCredentialsName))
201-
}
202-
return "", nil, fmt.Errorf("get PostgreSQLHostCredentials %s/%s: %w", namespace, hostCredentialsName, err)
203-
}
204-
205-
user, err := kube.ResourceValue(r.Client, hostCreds.Spec.User, hostCreds.Namespace)
206-
if err != nil {
207-
return "", nil, fmt.Errorf("resolve user: %w", err)
208-
}
209-
210-
password, err := kube.ResourceValue(r.Client, hostCreds.Spec.Password, hostCreds.Namespace)
211-
if err != nil {
212-
return "", nil, fmt.Errorf("resolve password: %w", err)
213-
}
214-
215-
host, err := kube.ResourceValue(r.Client, hostCreds.Spec.Host, hostCreds.Namespace)
216-
if err != nil {
217-
return "", nil, fmt.Errorf("resolve host: %w", err)
218-
}
219-
220-
return host, &postgres.Credentials{
221-
Name: user,
222-
User: user,
223-
Password: password,
224-
Params: hostCreds.Spec.Params,
225-
}, nil
226-
}
227-
228197
func (r *CustomRoleReconciler) persistStatus(ctx context.Context, customRole *postgresqlv1alpha1.CustomRole, reconcileErr error) {
229198
var phase postgresqlv1alpha1.CustomRolePhase
230199
var errorMessage string
@@ -252,3 +221,22 @@ func (r *CustomRoleReconciler) persistStatus(ctx context.Context, customRole *po
252221
r.Log.Error(err, "failed to update CustomRole status")
253222
}
254223
}
224+
225+
func customRoleRequeueStrategy(log logr.Logger, err error) (ctrl.Result, error) {
226+
if err == nil {
227+
return ctrl.Result{}, nil
228+
}
229+
230+
if ctlerrors.IsInvalid(err) {
231+
log.Info("Dropping CustomRole from queue as it is invalid", "error", err)
232+
return reconcile.Result{}, nil
233+
}
234+
235+
if ctlerrors.IsTemporary(err) {
236+
log.Info("Failed to reconcile CustomRole object, attempting again shortly", "error", err)
237+
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
238+
}
239+
240+
log.Info("Failed to reconcile CustomRole object due to unknown error", "error", err)
241+
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
242+
}

pkg/postgres/customrole.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,36 @@ type CustomRoleGrant struct {
1919
Privileges []string
2020
}
2121

22+
// allowedTablePrivileges is the set of valid PostgreSQL table-level privilege keywords.
23+
var allowedTablePrivileges = map[string]struct{}{
24+
"SELECT": {},
25+
"INSERT": {},
26+
"UPDATE": {},
27+
"DELETE": {},
28+
"TRUNCATE": {},
29+
"REFERENCES": {},
30+
"TRIGGER": {},
31+
"ALL": {},
32+
}
33+
34+
// validatePrivileges returns an error if any value in privs is not a recognised
35+
// PostgreSQL table-level privilege keyword. Comparison is case-insensitive.
36+
func validatePrivileges(privs []string) error {
37+
for _, p := range privs {
38+
if _, ok := allowedTablePrivileges[strings.ToUpper(p)]; !ok {
39+
return fmt.Errorf("invalid privilege %q: must be one of SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, ALL", p)
40+
}
41+
}
42+
return nil
43+
}
44+
2245
// EnsureCustomRole creates a PostgreSQL role if it does not exist and applies
2346
// server-level role grants. The role is created with NOLOGIN.
2447
func EnsureCustomRole(log logr.Logger, db *sql.DB, roleName string, grantRoles []string) error {
2548
log = log.WithValues("role", roleName)
2649
log.V(1).Info("Ensuring custom role")
2750

28-
_, err := db.Exec(fmt.Sprintf("CREATE ROLE %s NOLOGIN", roleName))
51+
_, err := db.Exec(fmt.Sprintf("CREATE ROLE %s NOLOGIN", pq.QuoteIdentifier(roleName)))
2952
if err != nil {
3053
pqError, ok := err.(*pq.Error)
3154
if !ok || pqError.Code.Name() != "duplicate_object" {
@@ -40,10 +63,14 @@ func EnsureCustomRole(log logr.Logger, db *sql.DB, roleName string, grantRoles [
4063
return nil
4164
}
4265

43-
joined := strings.Join(grantRoles, ", ")
44-
_, err = db.Exec(fmt.Sprintf("GRANT %s TO %s", joined, roleName))
66+
quotedRoles := make([]string, len(grantRoles))
67+
for i, r := range grantRoles {
68+
quotedRoles[i] = pq.QuoteIdentifier(r)
69+
}
70+
joined := strings.Join(quotedRoles, ", ")
71+
_, err = db.Exec(fmt.Sprintf("GRANT %s TO %s", joined, pq.QuoteIdentifier(roleName)))
4572
if err != nil {
46-
return fmt.Errorf("grant roles %s to %s: %w", joined, roleName, err)
73+
return fmt.Errorf("grant roles %s to %s: %w", strings.Join(grantRoles, ", "), roleName, err)
4774
}
4875
log.V(1).Info("Granted roles", "roles", grantRoles)
4976

@@ -122,22 +149,36 @@ func resolveSchemas(db *sql.DB, schema string) ([]string, error) {
122149
}
123150

124151
func applyPrivilegeGrant(log logr.Logger, db *sql.DB, roleName, schema, table string, privileges []string) error {
125-
privs := strings.Join(privileges, ", ")
152+
if err := validatePrivileges(privileges); err != nil {
153+
return err
154+
}
155+
156+
// Normalise keywords to uppercase for clarity; PostgreSQL is case-insensitive
157+
// for keywords but this keeps generated SQL consistent.
158+
upper := make([]string, len(privileges))
159+
for i, p := range privileges {
160+
upper[i] = strings.ToUpper(p)
161+
}
162+
privs := strings.Join(upper, ", ")
163+
164+
quotedRole := pq.QuoteIdentifier(roleName)
165+
quotedSchema := pq.QuoteIdentifier(schema)
126166

127-
_, err := db.Exec(fmt.Sprintf("GRANT USAGE ON SCHEMA %s TO %s", schema, roleName))
167+
_, err := db.Exec(fmt.Sprintf("GRANT USAGE ON SCHEMA %s TO %s", quotedSchema, quotedRole))
128168
if err != nil {
129169
return fmt.Errorf("grant usage on schema %s to %s: %w", schema, roleName, err)
130170
}
131171
log.V(1).Info("Granted USAGE on schema", "schema", schema)
132172

133173
if table == "" || table == "*" {
134-
_, err = db.Exec(fmt.Sprintf("GRANT %s ON ALL TABLES IN SCHEMA %s TO %s", privs, schema, roleName))
174+
_, err = db.Exec(fmt.Sprintf("GRANT %s ON ALL TABLES IN SCHEMA %s TO %s", privs, quotedSchema, quotedRole))
135175
if err != nil {
136176
return fmt.Errorf("grant %s on all tables in schema %s to %s: %w", privs, schema, roleName, err)
137177
}
138178
log.V(1).Info("Granted privileges on all tables in schema", "privileges", privs, "schema", schema)
139179
} else {
140-
_, err = db.Exec(fmt.Sprintf("GRANT %s ON TABLE %s.%s TO %s", privs, schema, table, roleName))
180+
quotedTable := pq.QuoteIdentifier(table)
181+
_, err = db.Exec(fmt.Sprintf("GRANT %s ON TABLE %s.%s TO %s", privs, quotedSchema, quotedTable, quotedRole))
141182
if err != nil {
142183
return fmt.Errorf("grant %s on table %s.%s to %s: %w", privs, schema, table, roleName, err)
143184
}

0 commit comments

Comments
 (0)