ユーザー入力を文字列連結で 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, $2 | WHERE 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句を付けるか、渡す前にエスケープします。
