Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions internal/import/d1/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,14 @@ func convertUniqueConstraint(clause string, table TableSchema) string {
// quoted identifiers case-sensitively, so the referenced table/columns are canonicalized
// to the actual declared case from all (the full set of parsed tables) rather than quoting
// whatever case happens to appear in this clause.
//
// Unrecognized REFERENCES text and unsafe action tails (statement terminators, extra
// parentheses, or tokens outside the FK-action grammar) are dropped rather than emitted
// verbatim into executed DDL.
func convertReferencesClause(refs string, all []TableSchema) string {
m := referencesClauseRe.FindStringSubmatch(refs)
if m == nil {
return refs
return ""
}
rawTable := firstNonEmpty(m[1], m[2], m[3], m[4])
refTable := tableByName(all, rawTable)
Expand All @@ -410,11 +414,34 @@ func convertReferencesClause(refs string, all []TableSchema) string {
}
table := postgres.QuoteIdentifier(tableName)
refCols := quoteColumnListFor(m[5], refTable)
tail := strings.TrimSpace(m[6])
if tail != "" {
return "REFERENCES " + table + " (" + refCols + ") " + tail
if refCols == "" {
return ""
}
out := "REFERENCES " + table + " (" + refCols + ")"
if tail := sanitizeReferencesTail(strings.TrimSpace(m[6])); tail != "" {
return out + " " + tail
}
return out
}

// fkActionTailRe matches a sequence of SQLite/Postgres foreign-key action clauses only.
// Anything outside this grammar (including ";" / extra ")" that could escape CREATE TABLE)
// is rejected by sanitizeReferencesTail.
var fkActionTailRe = regexp.MustCompile(`(?is)^(?:\s*(?:ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION)|MATCH\s+(?:SIMPLE|FULL|PARTIAL)|NOT\s+DEFERRABLE|DEFERRABLE(?:\s+INITIALLY\s+(?:DEFERRED|IMMEDIATE))?|INITIALLY\s+(?:DEFERRED|IMMEDIATE)))+$`)

// sanitizeReferencesTail returns tail when it is a safe FK action clause list, otherwise "".
func sanitizeReferencesTail(tail string) string {
tail = strings.TrimSpace(tail)
if tail == "" {
return ""
}
if strings.ContainsAny(tail, ";()") {
return ""
}
if !fkActionTailRe.MatchString(tail) {
return ""
}
return "REFERENCES " + table + " (" + refCols + ")"
return strings.Join(strings.Fields(tail), " ")
}

// quoteColumnList quotes a comma-separated column list, stripping SQLite indexed-column
Expand Down
62 changes: 62 additions & 0 deletions internal/import/d1/constraints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,68 @@ CREATE TABLE Posts (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES USERS(ID)
assertValidPostgresDDL(t, verifyDDL)
}

func TestConvertReferencesClauseKeepsSafeActionTail(t *testing.T) {
sql := `CREATE TABLE users (id INTEGER PRIMARY KEY);
CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE ON UPDATE SET NULL);
`
ddl := convertTablesDDL(t, sql)
if !strings.Contains(ddl, `REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE SET NULL`) {
t.Fatalf("expected safe FK action tail preserved:\n%s", ddl)
}
assertValidPostgresDDL(t, ddl)
}

func TestConvertReferencesClauseDropsInjectionTail(t *testing.T) {
got := convertReferencesClause(`REFERENCES users(id)); DROP TABLE users; --`, nil)
if strings.Contains(strings.ToUpper(got), "DROP TABLE") {
t.Fatalf("injected DROP must not appear in REFERENCES clause: %q", got)
}
if strings.Contains(got, ");") {
t.Fatalf("escaping parentheses must not appear in REFERENCES clause: %q", got)
}
if got != `REFERENCES "users" ("id")` {
t.Fatalf("got %q, want REFERENCES with action tail stripped", got)
}

got = convertReferencesClause(`REFERENCES users(id) ON DELETE CASCADE); DROP TABLE users; --`, nil)
if strings.Contains(strings.ToUpper(got), "DROP TABLE") || strings.Contains(got, "CASCADE") {
// Hostile tail must be dropped entirely (not partially trusted).
t.Fatalf("hostile mixed tail must be dropped: %q", got)
}
if got != `REFERENCES "users" ("id")` {
t.Fatalf("got %q, want REFERENCES without hostile tail", got)
}

if got := convertReferencesClause(`NOT A REFERENCES CLAUSE); DROP TABLE users; --`, nil); got != "" {
t.Fatalf("unparseable REFERENCES must be omitted, got %q", got)
}
}

// End-to-end: attacker dumps smuggle "); DROP ...; CREATE TABLE ..." after a real
// REFERENCES ... ON DELETE CASCADE close. Parse must stop at the balanced CREATE TABLE
// close, and conversion must never emit the injected statements into executed DDL.
func TestConvertReferencesTailInjectionEndToEnd(t *testing.T) {
sql := `CREATE TABLE a (id INTEGER PRIMARY KEY);
CREATE TABLE b ( x INTEGER REFERENCES a(id) ON DELETE CASCADE); DROP TABLE users; CREATE TABLE dummy (z int );
`
ddl := convertTablesDDL(t, sql)
upper := strings.ToUpper(ddl)
if strings.Contains(upper, "DROP TABLE") {
t.Fatalf("injected DROP must not appear in converted DDL:\n%s", ddl)
}
if strings.Contains(upper, `"DUMMY"`) || strings.Contains(ddl, `"dummy"`) {
t.Fatalf("smuggled CREATE TABLE must not appear in converted DDL:\n%s", ddl)
}
if !strings.Contains(ddl, `REFERENCES "a" ("id")`) {
t.Fatalf("expected safe REFERENCES clause:\n%s", ddl)
}
// Legitimate action should survive once the trailing junk is excluded from the body.
if !strings.Contains(ddl, `ON DELETE CASCADE`) {
t.Fatalf("expected ON DELETE CASCADE preserved after stripping smuggled SQL:\n%s", ddl)
}
assertValidPostgresDDL(t, ddl)
}

func TestQuoteColumnListStripsIndexedColumnModifiers(t *testing.T) {
got := quoteColumnList("a, b DESC")
want := `"a", "b"`
Expand Down
164 changes: 146 additions & 18 deletions internal/import/d1/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ func convertColumn(col ColumnSchema, table TableSchema, all []TableSchema, ctx *
}

if col.ForeignKey != "" {
parts = append(parts, convertReferencesClause(col.ForeignKey, all))
if refs := convertReferencesClause(col.ForeignKey, all); refs != "" {
parts = append(parts, refs)
}
}

return strings.Join(parts, " ")
Expand Down Expand Up @@ -254,26 +256,163 @@ func convertDefault(def, pgType string) (string, bool) {
return mapped, true
}
if pgType == "BOOLEAN" {
if lit := parseBooleanDefaultLiteral(def); lit != "" {
if lit := parseBooleanDefaultLiteral(unwrapOuterParens(def)); lit != "" {
return lit, true
}
if bu := strings.ToUpper(unwrapOuterParens(def)); bu == "TRUE" || bu == "FALSE" {
return bu, true
}
}
if pgType == "UUID" {
return "'" + strings.Trim(def, "'\"") + "'", true
return convertUUIDDefault(def)
}
if strings.HasPrefix(def, "'") {
// Already a valid (SQLite and Postgres share '' escaping) single-quoted literal.
return def, true
if lit, ok := validatedSingleQuotedLiteral(def); ok {
return lit, true
}
return "", false
}
if strings.HasPrefix(def, `"`) {
inner, ok := validatedDoubleQuotedLiteral(def)
if !ok {
return "", false
}
// SQLite's double-quoted string-literal fallback: Postgres always treats double
// quotes as identifiers, so re-quote as a proper single-quoted string literal.
return quotePostgresLiteral(unwrapDoubleQuotedLiteral(def)), true
return quotePostgresLiteral(inner), true
}
if pgType == "TEXT" || pgType == "TIMESTAMPTZ" {
return quotePostgresLiteral(strings.Trim(def, "'\"")), true
}
return def, true
// Typed non-text columns (BIGINT, NUMERIC, BYTEA, …): only emit validated scalar
// literals. Never pass an unrecognized attacker-controlled expression through
// verbatim into executed DDL — that lets unbalanced ")" / ";" escape CREATE TABLE.
if lit, ok := safeNumericDefaultLiteral(def); ok {
return lit, true
}
return "", false
}

// convertUUIDDefault emits a safely quoted UUID default, or omits unrecognized input.
func convertUUIDDefault(def string) (string, bool) {
def = strings.TrimSpace(def)
if strings.HasPrefix(def, "'") {
return validatedSingleQuotedLiteral(def)
}
if strings.HasPrefix(def, `"`) {
inner, ok := validatedDoubleQuotedLiteral(def)
if !ok {
return "", false
}
return quotePostgresLiteral(inner), true
}
if isSafeBareUUIDDefault(def) {
return quotePostgresLiteral(def), true
}
return "", false
}

// validatedSingleQuotedLiteral accepts a complete SQLite/Postgres single-quoted string
// literal (with '' escapes) and rejects unclosed quotes or trailing junk after the
// closing quote — both of which can break out of DEFAULT into adjacent SQL.
func validatedSingleQuotedLiteral(s string) (string, bool) {
s = strings.TrimSpace(s)
if len(s) < 2 || s[0] != '\'' {
return "", false
}
for i := 1; i < len(s); i++ {
if s[i] != '\'' {
continue
}
if i+1 < len(s) && s[i+1] == '\'' {
i++
continue
}
if i != len(s)-1 {
return "", false
}
return s, true
}
return "", false
}

// validatedDoubleQuotedLiteral accepts a complete double-quoted SQLite string-literal
// fallback (with "" escapes) and returns the unescaped inner text. Trailing junk after
// the closing quote is rejected.
func validatedDoubleQuotedLiteral(s string) (string, bool) {
s = strings.TrimSpace(s)
if len(s) < 2 || s[0] != '"' {
return "", false
}
var inner strings.Builder
for i := 1; i < len(s); i++ {
if s[i] != '"' {
inner.WriteByte(s[i])
continue
}
if i+1 < len(s) && s[i+1] == '"' {
inner.WriteByte('"')
i++
continue
}
if i != len(s)-1 {
return "", false
}
return inner.String(), true
}
return "", false
}

// isSafeBareUUIDDefault reports whether s is a bare UUID-shaped token safe to quote as a
// default (hex digits and hyphens only).
func isSafeBareUUIDDefault(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F', c == '-':
default:
return false
}
}
return true
}

// safeNumericDefaultLiteral accepts a simple numeric literal, optionally wrapped in
// balanced outer parentheses (e.g. "(0)", "((-1))"). Anything else — expressions,
// statement terminators, unbalanced parentheses — is rejected so it cannot be spliced
// into CREATE TABLE.
func safeNumericDefaultLiteral(def string) (string, bool) {
unwrapped := strings.TrimSpace(unwrapOuterParens(def))
if unwrapped == "" {
return "", false
}
if _, err := strconv.ParseInt(unwrapped, 10, 64); err == nil {
return unwrapped, true
}
if _, err := strconv.ParseFloat(unwrapped, 64); err == nil {
upper := strings.ToUpper(unwrapped)
if strings.Contains(upper, "INF") || strings.Contains(upper, "NAN") {
return "", false
}
return unwrapped, true
}
lower := strings.ToLower(unwrapped)
if strings.HasPrefix(lower, "0x") {
hex := unwrapped[2:]
if n, err := strconv.ParseUint(hex, 16, 64); err == nil {
return strconv.FormatUint(n, 10), true
}
}
if strings.HasPrefix(lower, "-0x") {
hex := unwrapped[3:]
if n, err := strconv.ParseUint(hex, 16, 63); err == nil {
return strconv.FormatInt(-int64(n), 10), true
}
}
return "", false
}

// quotePostgresLiteral wraps s as a Postgres single-quoted string literal, doubling any
Expand All @@ -282,17 +421,6 @@ func quotePostgresLiteral(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

// unwrapDoubleQuotedLiteral strips the surrounding double quotes from SQLite's
// double-quoted string-literal fallback syntax (e.g. `"active"`), un-escaping any doubled
// internal quotes. Returns s unchanged if it isn't double-quoted.
func unwrapDoubleQuotedLiteral(s string) string {
if len(s) >= 2 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
inner := s[1 : len(s)-1]
return strings.ReplaceAll(inner, `""`, `"`)
}
return s
}

// unwrapOuterParens repeatedly strips a single matching outer paren pair that wraps the
// entire expression, e.g. "((random()))" -> "random()".
func unwrapOuterParens(s string) string {
Expand Down
Loading
Loading