プリペアドステートメントで安全にSQLを実行する

SQL プラグインの execute / select にプレースホルダーと値の配列を渡し、SQL インジェクションを防ぐ。DB ごとの記法、一括挿入、Rust の sqlx での書き方も示す。

データ保存 対象: Tauri 2.x 更新日: 読了目安: 約6分 db-002
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. やってはいけない例
  4. 安全な例
  5. DB ごとのプレースホルダー記法
  6. 複数行を一括挿入する
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

ユーザー入力を文字列連結で SQL に埋め込むと SQL インジェクションの入口になります。tauri-plugin-sql の execute() / select() は第 2 引数に値の配列を受け取り、SQL 本文とは別経路で DB に渡す(プリペアドステートメント)ため、値にどんな記号が含まれていても構文として解釈されません。DB ごとの記法、戻り値、一括挿入、Rust 側で sqlx を直接使う場合まで示します。

前提条件

npm run tauri add sql
cd src-tauri
cargo add tauri-plugin-sql --features sqlite          # mysql / postgres も可
cargo add sqlx@0.8 --features sqlite,runtime-tokio    # 2 章で Rust から直接使う場合

sql:default に含まれるのは allow-load / allow-select / allow-close だけです。書き込みには sql:allow-execute を足します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": ["core:default", "sql:default", "sql:allow-execute"]
}

1. フロントエンドから実装する (TypeScript)

やってはいけない例

import Database from '@tauri-apps/plugin-sql';

const db = await Database.load('sqlite:app.db');
const userInput = (document.querySelector('#name') as HTMLInputElement).value;

// userInput が  ' OR '1'='1  なら全行が返り、'; DROP TABLE users; -- ならテーブルが消える
const rows = await db.select(`SELECT * FROM users WHERE name = '${userInput}'`);

安全な例

import Database from '@tauri-apps/plugin-sql';

const db = await Database.load('sqlite:app.db'); // 相対パスは $APPCONFIG 配下
const userInput = (document.querySelector('#name') as HTMLInputElement).value;
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)');

// 値は配列で渡す。順番が $1, $2 に対応する
const result = await db.execute('INSERT INTO users (name, age) VALUES ($1, $2)', [userInput, 30]);
console.log(result.rowsAffected, result.lastInsertId); // 1, 採番された id

// 戻り値は「列名をキーにしたオブジェクト」の配列。0 件なら []
type User = { id: number; name: string; age: number };
const rows = await db.select<User[]>('SELECT id, name, age FROM users WHERE name = $1', [userInput]);

execute() は { rowsAffected, lastInsertId } を返します。lastInsertId は SQLite / MySQL 用で、PostgreSQL では INSERT ... RETURNING id を select() で実行して取り出します。

DB ごとのプレースホルダー記法

DB記法例
SQLite$1, $2(? も可)WHERE id = $1
PostgreSQL$1, $2WHERE id = $1
MySQL?WHERE id = ?

同じ値を 2 回使う場合、$1 は 2 回書けますが、MySQL の ? は出現回数分だけ配列に入れます。

複数行を一括挿入する

行数分の VALUES を生成し、値は 1 本の配列に平坦化します。SQLite にはバインド変数の上限(既定 32766、古いビルドでは 999)があるので分割します。1 文の INSERT は途中で失敗すると丸ごと取り消されますが、分割した文どうしはまとまりません。JS から BEGIN / COMMIT を送ってもトランザクションにならないことがあるので、全件をまとめて確定したいときは Rust 側で行います。

import Database from '@tauri-apps/plugin-sql';

// 500 行ずつ 1 文の INSERT にする(確定は 1 文ごと)
export async function insertMany(db: Database, users: { name: string; age: number }[]) {
  const chunk = 500;
  for (let i = 0; i < users.length; i += chunk) {
    const part = users.slice(i, i + chunk);
    const ph = part.map((_, j) => `($${j * 2 + 1}, $${j * 2 + 2})`).join(', ');
    await db.execute(`INSERT INTO users (name, age) VALUES ${ph}`, part.flatMap((u) => [u.name, u.age]));
  }
}

2. バックエンドから実装する (Rust)

SQL をフロントに見せたくない場合は Rust で sqlx を直接使います。bind() がプリペアドステートメントに相当します。tauri-plugin-sql と併用するなら cargo tree -i sqlx で版を確認し、メジャーバージョンを揃えます。

use sqlx::{sqlite::SqlitePoolOptions, Row, SqlitePool};
use tauri::{Manager, State};

struct Db(SqlitePool);

#[tauri::command]
async fn find_users(db: State<'_, Db>, name: String) -> Result<Vec<(i64, String)>, String> {
    let rows = sqlx::query("SELECT id, name FROM users WHERE name = ?")
        .bind(name) // 値は SQL 本文と別に送られる。PostgreSQL なら $1
        .fetch_all(&db.0).await.map_err(|e| e.to_string())?;
    Ok(rows.iter().map(|r| (r.get("id"), r.get("name"))).collect())
}

pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let dir = app.path().app_config_dir()?;
            std::fs::create_dir_all(&dir)?;
            let url = format!("sqlite://{}?mode=rwc", dir.join("app.db").display());
            let pool = tauri::async_runtime::block_on(SqlitePoolOptions::new().connect(&url))?;
            app.manage(Db(pool));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![find_users])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

const userInput = (document.querySelector('#name') as HTMLInputElement).value;
const users = await invoke<[number, string][]>('find_users', { name: userInput });

動作確認

npm run tauri dev で起動し、検索欄に ' OR '1'='1 を入れてください。文字列連結版では全行が返り、プレースホルダー版では名前がその文字列そのものの行だけ(通常 0 件)が返ります。挿入後の console.log(result) は { rowsAffected: 1, lastInsertId: 3 } のように出ます。

よくあるエラーと対処法

  • 「sql.execute not allowed. Permissions associated with this command: sql:allow-execute」: sql:default に execute は含まれません。sql:allow-execute を追加します。
  • MySQL で $1 が構文エラー: MySQL は ? のみです。逆に PostgreSQL で ? は使えません。接続先を変えたら SQL も見直します。
  • 値の数とプレースホルダーの数が合わない: SQLite ではエラーにならず、足りない分は NULL として実行され、余った値は無視されます。一括挿入では flatMap の要素数と VALUES の数が合っているか確かめます。
  • no such table: Database.load はテーブルを作りません。CREATE TABLE IF NOT EXISTS か マイグレーション で用意します。

OS ごとの違いと注意点

  • sqlite:app.db は各 OS の設定フォルダ(Windows: %APPDATA%\<identifier>、macOS: ~/Library/Application Support/<identifier>、Linux: ~/.config/<identifier>)に展開されます。
  • 守れるのは「値」だけです。テーブル名・列名・ORDER BY の方向は埋め込めないので、許可リストと照合してから文字列に組み込みます。
  • LIKE の % と _ は値の中でも意味を持ちます。ESCAPE 句を付けるか、渡す前にエスケープします。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する