fix(db): declare TOTP param DataType so .Clear works on first row

FireDAC raises EFDException -335 "data type unknown" when .Clear is
called on a TFDParam before any typed value has been assigned. Hit
in PM.Handler.Auth.HandleChangeMasterPassword when the first entry
in the migration loop had no TOTP secret — already fixed inline.

Same latent bug existed in every other handler that touches the
optional totp_secret / totp_iv columns:
 - HandleCreateEntry      (Entries.pas)
 - HandleUpdateEntry      (Entries.pas)
 - HandleBulkImport       (Entries.pas)

All three now declare:
  LQ.ParamByName('ts').DataType  := ftString;
  LQ.ParamByName('tiv').DataType := ftString;
right after setting SQL.Text, so the very first .Clear (when an
entry has no TOTP) doesn't fail with "data type unknown" on the
SQLite param binding path.

For HandleBulkImport the declaration is hoisted out of the per-entry
loop since the prepared statement is reused across iterations.
This commit is contained in:
2026-05-23 16:17:01 +01:00
parent d13e5bc89f
commit 664db65437
2 changed files with 24 additions and 4 deletions
+8 -4
View File
@@ -1,4 +1,4 @@
unit PM.Handler.Auth; unit PM.Handler.Auth;
(* (*
/register POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} /register POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken}
@@ -21,7 +21,7 @@ implementation
uses uses
System.SysUtils, System.JSON, System.Classes, System.SysUtils, System.JSON, System.Classes,
FireDAC.Comp.Client, FireDAC.Comp.Client,
IdCustomHTTPServer, IdCustomHTTPServer,Data.DB,
PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Router, PM.JSON, PM.Database, PM.Crypto,
PM.Session, PM.RateLimit, PM.Audit; PM.Session, PM.RateLimit, PM.Audit;
@@ -938,8 +938,12 @@ begin
LQ.ParamByName('iv').AsString := LIv; LQ.ParamByName('iv').AsString := LIv;
// TOTP fields are optional per entry — clear when empty so // TOTP fields are optional per entry — clear when empty so
// existing-NULL rows don't get stomped with empty strings. // existing-NULL rows don't get stomped with empty strings.
if LTotpSec = '' then LQ.ParamByName('ts').Clear LQ.ParamByName('ts').DataType := ftString;
else LQ.ParamByName('ts').AsString := LTotpSec; LQ.ParamByName('tiv').DataType := ftString;
if LTotpSec.IsEmpty then
LQ.ParamByName('ts').Clear
else
LQ.ParamByName('ts').AsString := LTotpSec;
if LTotpIv = '' then LQ.ParamByName('tiv').Clear if LTotpIv = '' then LQ.ParamByName('tiv').Clear
else LQ.ParamByName('tiv').AsString := LTotpIv; else LQ.ParamByName('tiv').AsString := LTotpIv;
LQ.ExecSQL; LQ.ExecSQL;
@@ -185,6 +185,12 @@ begin
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
LQ.ParamByName('t').AsString := LTags; LQ.ParamByName('t').AsString := LTags;
// FireDAC needs an explicit DataType on params that are sometimes
// assigned a string and sometimes Clear()ed to NULL — without a
// prior typed assignment, .Clear raises "data type unknown" on
// SQLite. Declare ftString up front for the optional TOTP fields.
LQ.ParamByName('ts').DataType := ftString;
LQ.ParamByName('tiv').DataType := ftString;
// Store empty TOTP fields as NULL so the GET endpoint emits JSON null // Store empty TOTP fields as NULL so the GET endpoint emits JSON null
// rather than '' — keeps client-side "has TOTP?" checks unambiguous. // rather than '' — keeps client-side "has TOTP?" checks unambiguous.
if LTotpSec = '' then if LTotpSec = '' then
@@ -278,6 +284,10 @@ begin
LQ.ParamByName('i').AsString := LIV; LQ.ParamByName('i').AsString := LIV;
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
LQ.ParamByName('t').AsString := LTags; LQ.ParamByName('t').AsString := LTags;
// Declare TOTP param types so .Clear works on first use (FireDAC
// needs an inferred or explicit DataType before NULL binding).
LQ.ParamByName('ts').DataType := ftString;
LQ.ParamByName('tiv').DataType := ftString;
// Clearing TOTP (user removed 2FA from this entry) is signaled by an // Clearing TOTP (user removed 2FA from this entry) is signaled by an
// empty string in the request → store NULL in the DB. // empty string in the request → store NULL in the DB.
if LTotpSec = '' then if LTotpSec = '' then
@@ -533,6 +543,12 @@ begin
'(user_id, site, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' + ' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' +
'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; 'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
// Declare optional TOTP param types ONCE — the prepared statement
// is reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail
// for a row without TOTP.
LQ.ParamByName('ts').DataType := ftString;
LQ.ParamByName('tiv').DataType := ftString;
for I := 0 to LArr.Count - 1 do for I := 0 to LArr.Count - 1 do
begin begin