byob-go-cli

Every SQL statement uses placeholders; never string-concatenated values

byob-input-validation.4 input-validationstorage

Problem: fmt.Sprintf("SELECT * FROM items WHERE name = '%s'", name) with a name coming from anywhere user-controlled is the canonical SQL-injection vector. It stays a problem even in small CLIs: "name" may start as safely-typed config but later get populated from a flag value or a parsed file. The only robust fix is making the wrong shape syntactically impossible to write.

Idea: database/sql's placeholder API is the required form. ? for sqlite and mysql drivers, $1/$2 for postgres. The driver substitutes the values at protocol level — they never pass through a SQL parser as text. That makes injection impossible by construction.

The supporting decision byob-storage.1 ("per-backend Store implementations; no query builder, no ORM") ties in here: because queries are hand-written SQL, the discipline is visible in every query method. A code review rule becomes "any fmt.Sprintf near a SQL string is a bug." Structural linters (go vet -vettool, semgrep) can enforce this pattern if the team wants machine backing.

Identifiers (table names, column names) are the one case placeholders don't cover — drivers won't parameterize them. When they must be dynamic, validate against an allowlist: either a fixed slice of known-good identifiers or a strict regex (^[a-zA-Z_][a-zA-Z0-9_]*$). Reject anything else.

Tradeoffs: none on the value side. The identifier-allowlist rule is the only friction, and it applies to a small fraction of queries.

When not to use: never relax. The template's storage stance (byob-storage) assumes placeholder-only queries throughout.

Design

// WRONG: format-string interpolation, injection vulnerability.
func getItemBad(ctx context.Context, db *sql.DB, name string) (*Item, error) {
    row := db.QueryRowContext(ctx,
        fmt.Sprintf("SELECT id, name FROM items WHERE name = '%s'", name))
    // ...
}

// RIGHT: placeholder. sqlite/mysql use `?`, postgres uses `$1`.
func getItem(ctx context.Context, db *sql.DB, name string) (*Item, error) {
    row := db.QueryRowContext(ctx,
        `SELECT id, name FROM items WHERE name = ?`, name)
    var it Item
    if err := row.Scan(&it.ID, &it.Name); err != nil {
        return nil, err
    }
    return &it, nil
}

// Dynamic column name with allowlist:
var sortableColumns = map[string]bool{
    "name": true, "created_at": true, "id": true,
}

func listItemsSorted(ctx context.Context, db *sql.DB, sortBy string) (*sql.Rows, error) {
    if !sortableColumns[sortBy] {
        return nil, cmdutil.FlagErrorf("--sort must be one of name|created_at|id")
    }
    // column name is safe here because it's been validated against the allowlist
    q := fmt.Sprintf(`SELECT id, name FROM items ORDER BY %s`, sortBy)
    return db.QueryContext(ctx, q)
}