Skip to content

fix(dbconn): stop interpreting user DDL as an escape format string#1037

Draft
morgo wants to merge 1 commit into
block:mainfrom
morgo:fix/ddl-format-string-escape
Draft

fix(dbconn): stop interpreting user DDL as an escape format string#1037
morgo wants to merge 1 commit into
block:mainfrom
morgo:fix/ddl-format-string-escape

Conversation

@morgo

@morgo morgo commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Several call sites build the sqlescape format string by concatenating raw user SQL onto a trusted prefix, e.g. "ALTER TABLE %n ALGORITHM=INSTANT, " + c.stmt.Alter. sqlescape.EscapeSQL interprets %n / %? / %% anywhere in the text — including inside string literals of the user's DDL:

  • An --alter containing %n or %? in a literal (e.g. COMMENT '100%new', CHECK (name LIKE 'a%?')) fails escaping with missing arguments. On the default force-kill path this goes through sqlescape.MustEscapeSQL, which panics the process — with the kill timer already armed.
  • %% in a literal (e.g. DEFAULT '50%% off') is silently collapsed to a single %, so spirit executes different DDL than the user wrote ('50% off' instead of '50%% off').

Non-ALTER statements (CREATE TABLE / DROP TABLE / RENAME TABLE) were also executed by passing the raw user statement as the format string with zero args.

Fix

User SQL is now always data, never a format string:

  • New dbconn.ExecRaw(ctx, db, stmt) and dbconn.ForceExecRaw(ctx, db, tables, dbConfig, logger, stmt) execute a fully-built statement with no format interpretation. ForceExec is now a thin wrapper over ForceExecRaw that escapes with the error-returning EscapeSQL (no panic) before the kill timer is armed — this also removes the previous panic-inside-timer-window hazard. Kill-timer/retry semantics are unchanged.
  • Callers escape only the trusted prefix (e.g. sqlescape.MustEscapeSQL("ALTER TABLE %n ALGORITHM=INSTANT, ", tableName)) and append the user clause verbatim, executing via the raw variants.

Call sites audited and fixed (all places where a non-constant format string reached Exec/ForceExec):

  • pkg/migration/change.go alterNewTableALGORITHM=COPY attempt and the plain retry
  • pkg/migration/change.go attemptInstantDDLForceExec and Exec variants
  • pkg/migration/change.go attemptInplaceDDLForceExec and Exec variants
  • pkg/migration/runner.go — non-ALTER single-statement execution (stmt.Statement was the format string)

All other Exec / ForceExec / EscapeSQL call sites were audited and use compile-time constant format strings (incl. checkpoint.go's "CREATE TABLE %n " + tableDDL, which concatenates two package constants); they are unchanged. pkg/move / pkg/datasync already execute fetched DDL via plain ExecContext.

Testing

New regression tests (verified to fail without the fix — the instant-path test reproduces the process panic missing arguments, need 2-th arg, but only got 1 args):

  • pkg/migration TestPercentSignsInDDLLiterals:
    • instant path with COMMENT '100%new, a%?b' — no panic, comment lands exactly as written in SHOW CREATE TABLE (exercises the ForceExecRaw path, SkipForceKill=false)
    • instant path with DEFAULT '50%% off' COMMENT 'literal 50%%' — the same clause is run directly with a plain client on a sibling table and the resulting column definitions must be byte-identical (previously spirit stored '50% off')
    • copy path (ADD INDEX forces copy) with DEFAULT 'copy a%?b' — exercises alterNewTable
    • non-ALTER path: CREATE TABLE ... DEFAULT '50%% off' COMMENT '100%new' via --statement
  • pkg/dbconn TestExecRaw (literals reach the server verbatim; Exec on the same text still errors), TestForceExecRaw (no format interpretation and the MDL-blocker force-kill still works), TestForceExecBadFormatString (bad format now returns an error before the kill timer is armed, instead of panicking)

Runs against MySQL 8.0.45:

  • full ./pkg/dbconn/... suite: pass
  • targeted ./pkg/migration tests covering the touched paths (TestPercentSignsInDDLLiterals, TestForNonInstantBurn, TestIndexVisibility, TestStatementWorkflowStillInstant, TestTrailingSemicolon, TestAlterExtendVarcharE2E, TestMigrationWithSQLCommentsInStatement, TestStmtWorkflow, TestCreateIndexIsRewritten, TestRenameInMySQL80, TestSchemaNameIncluded, change.go tests): pass
  • go build, go vet, gofmt, golangci-lint: clean

🤖 Generated with Claude Code

Callers built the sqlescape format string by concatenating raw user SQL
onto a trusted prefix (e.g. "ALTER TABLE %n ALGORITHM=INSTANT, " +
stmt.Alter). sqlescape interprets %n/%?/%% anywhere in the text,
including inside string literals of the user's DDL:

- An alter containing %n or %? in a literal (e.g. COMMENT '100%new')
  failed escaping with "missing arguments", which PANICKED the process
  on the ForceExec path (MustEscapeSQL), the default path.
- %% in a literal (e.g. DEFAULT '50%% off') was silently collapsed to a
  single %, so spirit executed different DDL than the user wrote.

Fix: user SQL is now data, never format. Add dbconn.ExecRaw and
dbconn.ForceExecRaw which execute a fully-built statement with no
format interpretation, and route all statements embedding user SQL
through them. Trusted prefixes are escaped separately and the user
clause appended verbatim. ForceExec is now a thin wrapper that escapes
(error-returning, no panic) BEFORE the kill timer is armed, removing
the panic-inside-timer-window hazard; kill-timer semantics unchanged.

Call sites fixed:
- migration/change.go alterNewTable (ALGORITHM=COPY attempt + retry)
- migration/change.go attemptInstantDDL (ForceExec + Exec variants)
- migration/change.go attemptInplaceDDL (ForceExec + Exec variants)
- migration/runner.go non-ALTER statement execution (raw stmt.Statement
  was the format string)

All other Exec/ForceExec/EscapeSQL call sites use compile-time constant
format strings and are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant