Skip to content

Commit 65ac468

Browse files
baozhoutaoclaude
andauthored
fix(import): sanitize row errors — never leak raw SQL, friendly constraint messages (#3566) (#3572)
Import rows that hit a DB constraint surfaced the driver's raw error verbatim — including the full failing SQL statement (e.g. `insert into sys_user (...) - UNIQUE constraint failed: sys_user.phone_number`), which is unreadable and leaks the schema. - sanitizeRowError() maps SQLite/MySQL/Postgres UNIQUE and NOT NULL failures to human wording and, as a backstop, never lets a raw SQL statement reach the client. Already-friendly messages (better-auth's "User already exists") pass through. - isLikelyEmail rejects non-ASCII, so `x@柴仟.com` fails the import dry-run instead of only at real-import time inside better-auth. - Unit tests for both. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bbda6e7 commit 65ac468

5 files changed

Lines changed: 159 additions & 1 deletion

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(import): sanitize row errors — never leak raw SQL, map constraint failures to human wording (#3566)
7+
8+
A failing import row surfaced the driver's raw error verbatim. When a write hit
9+
a DB constraint (e.g. `sys_user.phone_number` is `unique`), the query builder
10+
embeds the entire failing statement in `err.message`, and `toFailedResult`
11+
handed that straight back — so the importer saw ``insert into `sys_user`
12+
(...) values (...) - UNIQUE constraint failed: sys_user.phone_number``. That is
13+
both unreadable and an information disclosure of the schema.
14+
15+
- `sanitizeRowError()` (import-runner) maps the common constraint failures —
16+
SQLite / MySQL / Postgres `UNIQUE` and `NOT NULL` — to human wording
17+
("A record with this `<column>` already exists.", "`<column>` is required.")
18+
and, as a backstop, never lets a message that still reads as a SQL statement
19+
reach the client (it salvages the driver's trailing reason, or falls back to
20+
a generic message). Already-friendly messages (e.g. better-auth's "User
21+
already exists") pass through unchanged. Applies to every import path.
22+
- `isLikelyEmail` now rejects non-ASCII addresses, so an address like
23+
`x@柴仟.com` fails the import **dry-run** pre-check instead of passing client
24+
and dry-run validation only to be rejected by better-auth's strict ASCII
25+
validator at real-import time.

packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ describe('isLikelyEmail (linear-time, no regex backtracking)', () => {
8383
expect(isLikelyEmail('has space@b.co')).toBe(false);
8484
});
8585

86+
it('rejects non-ASCII addresses (framework#3566)', async () => {
87+
const { isLikelyEmail } = await import('./admin-user-endpoints.js');
88+
// Chinese domain — passes the structural checks but better-auth's strict
89+
// ASCII validator would reject it, so reject it here (and in the dry-run).
90+
expect(isLikelyEmail('735431496@柴仟.com')).toBe(false);
91+
// Full-width / invisible characters anywhere in the address.
92+
expect(isLikelyEmail('abc@b.com')).toBe(false);
93+
expect(isLikelyEmail('a@b​.com')).toBe(false);
94+
});
95+
8696
it('is fast on the CodeQL adversarial inputs', async () => {
8797
const { isLikelyEmail } = await import('./admin-user-endpoints.js');
8898
const attack = '!@'.repeat(100_000);

packages/plugins/plugin-auth/src/admin-user-endpoints.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ function badRequest(message: string): EndpointResult {
187187
*/
188188
export function isLikelyEmail(value: string): boolean {
189189
if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false;
190+
// Reject non-ASCII up front so obviously-invalid addresses (e.g. a Chinese
191+
// domain like `x@柴仟.com`) fail this pre-filter — and therefore the import
192+
// dry-run — instead of passing here only to be rejected later by
193+
// better-auth's strict ASCII validator at real-import time (framework#3566).
194+
// A single linear char-class test; no backtracking (see the ReDoS note above).
195+
if (/[^\x00-\x7f]/.test(value)) return false;
190196
const at = value.indexOf('@');
191197
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false;
192198
const domain = value.slice(at + 1);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* sanitizeRowError() — driver/query-builder errors embed the whole failing SQL
5+
* statement in `err.message`; it must never reach the importer verbatim
6+
* (framework#3566). Common constraint failures map to human wording.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { sanitizeRowError } from './import-runner';
11+
12+
describe('sanitizeRowError', () => {
13+
it('maps a sqlite UNIQUE violation to a friendly, SQL-free message', () => {
14+
const raw =
15+
"insert into `sys_user` (`email`, `name`, `phone_number`) values " +
16+
"('a@b.com', 'zhoujunyi', '13800000000') - UNIQUE constraint failed: sys_user.phone_number";
17+
const out = sanitizeRowError(raw);
18+
expect(out).toBe('A record with this phone_number already exists.');
19+
expect(out).not.toMatch(/insert into/i);
20+
});
21+
22+
it('maps a mysql duplicate entry', () => {
23+
const raw =
24+
"insert into `sys_user` ... - ER_DUP_ENTRY: Duplicate entry 'a@b.com' for key 'sys_user.email'";
25+
expect(sanitizeRowError(raw)).toBe('A record with this email already exists.');
26+
});
27+
28+
it('maps a postgres unique violation', () => {
29+
const raw =
30+
'insert into "sys_user" ... - duplicate key value violates unique constraint "sys_user_email_key" ' +
31+
'Detail: Key (email)=(a@b.com) already exists.';
32+
expect(sanitizeRowError(raw)).toBe('A record with this email already exists.');
33+
});
34+
35+
it('maps a NOT NULL violation', () => {
36+
const raw = 'insert into `sys_user` (...) values (...) - NOT NULL constraint failed: sys_user.name';
37+
expect(sanitizeRowError(raw)).toBe('name is required.');
38+
});
39+
40+
it('never leaks a raw SQL statement even for an unrecognized reason', () => {
41+
const raw = 'insert into `sys_user` (`email`) values (?) - some cryptic driver failure';
42+
const out = sanitizeRowError(raw);
43+
expect(out).not.toMatch(/insert into/i);
44+
// salvages the trailing non-SQL reason
45+
expect(out).toBe('some cryptic driver failure');
46+
});
47+
48+
it('falls back to a generic message when only SQL is present', () => {
49+
const raw = 'insert into `sys_user` (`email`) values (?)';
50+
expect(sanitizeRowError(raw)).toBe(
51+
'The database rejected this row (a value may be invalid or already in use).',
52+
);
53+
});
54+
55+
it('passes already-friendly messages through unchanged', () => {
56+
expect(sanitizeRowError('User already exists. Use another email.')).toBe(
57+
'User already exists. Use another email.',
58+
);
59+
});
60+
61+
it('handles empty / non-string input', () => {
62+
expect(sanitizeRowError('')).toBe('Row failed');
63+
expect(sanitizeRowError(undefined)).toBe('Row failed');
64+
expect(sanitizeRowError(null)).toBe('Row failed');
65+
});
66+
});

packages/rest/src/import-runner.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,60 @@ function extractRecordId(rec: any): string | undefined {
144144
return id != null ? String(id) : undefined;
145145
}
146146

147+
/** Does this text begin with a SQL statement? (leaked driver query builder output) */
148+
function looksLikeSql(text: string): boolean {
149+
return /^\s*(insert|update|delete|select|with|replace)\s/i.test(text);
150+
}
151+
152+
/** Strip a `table.column` (or quoted) constraint target down to a bare column name. */
153+
function bareColumn(raw: string): string {
154+
const col = raw.trim().replace(/[`"']/g, '');
155+
const dot = col.lastIndexOf('.');
156+
return dot >= 0 ? col.slice(dot + 1) : col;
157+
}
158+
159+
/**
160+
* Turn a raw write error into a message safe to hand back to the importer.
161+
*
162+
* Driver / query-builder errors (knex et al.) embed the *entire* failing SQL
163+
* statement in `err.message` — e.g. ``insert into `sys_user` (...) values
164+
* (...) - UNIQUE constraint failed: sys_user.phone_number``. Surfacing that
165+
* verbatim is both unreadable and an information disclosure of the schema
166+
* (framework#3566). This maps the common constraint failures to human wording
167+
* and, as a backstop, never lets a raw SQL statement escape to the client.
168+
*/
169+
export function sanitizeRowError(raw: unknown): string {
170+
const msg = typeof raw === 'string' ? raw.trim() : '';
171+
if (!msg) return 'Row failed';
172+
173+
// UNIQUE — surface the offending column (it maps to a user-facing import
174+
// column, so naming it is helpful, not a schema leak).
175+
const unique =
176+
/unique constraint failed:\s*([^\s,)]+)/i.exec(msg) ?? // sqlite
177+
/duplicate entry .* for key '([^']+)'/i.exec(msg) ?? // mysql
178+
/duplicate key value violates unique constraint.*?[Kk]ey \(([^)]+)\)/is.exec(msg); // postgres
179+
if (unique) return `A record with this ${bareColumn(unique[1])} already exists.`;
180+
181+
// NOT NULL — a required value is missing.
182+
const notNull = /not null constraint failed:\s*([^\s,)]+)/i.exec(msg);
183+
if (notNull) return `${bareColumn(notNull[1])} is required.`;
184+
185+
// Backstop: anything that still reads as a SQL statement must not reach the
186+
// client. Prefer the driver's trailing reason (after `... - <reason>`) when
187+
// it is itself not SQL; otherwise fall back to a generic message.
188+
if (looksLikeSql(msg)) {
189+
const sep = msg.lastIndexOf(' - ');
190+
const reason = sep >= 0 ? msg.slice(sep + 3).trim() : '';
191+
if (reason && !looksLikeSql(reason)) return reason.slice(0, 300);
192+
return 'The database rejected this row (a value may be invalid or already in use).';
193+
}
194+
195+
return msg.slice(0, 300);
196+
}
197+
147198
function toFailedResult(rowNo: number, err: unknown): ImportRowResult {
148199
const code = (err as any)?.code ?? 'IMPORT_ROW_FAILED';
149-
const message = typeof (err as any)?.message === 'string' ? (err as any).message.slice(0, 300) : 'Row failed';
200+
const message = sanitizeRowError((err as any)?.message);
150201
return { row: rowNo, ok: false, action: 'failed', error: message, code };
151202
}
152203

0 commit comments

Comments
 (0)