fix(LCEL-08): move LCEOnline token to Rust-side secret store

the access token used to live in localStorage under lceonline_session.
localStorage is readable by any JS in the webview origin (XSS exposure).

added three new IPC commands:
- lceonline_token_store(token, username): writes session.json under
  app_data_dir with 0600 perms (POSIX).
- lceonline_token_load(): reads session.json and returns {token, username}.
- lceonline_token_clear(): removes session.json.

the frontend now uses invoke() to call these instead of localStorage.
the token never enters the webview's storage surface.
This commit is contained in:
M1noa 2026-07-16 18:10:14 -05:00
parent 3ab1e2cffa
commit 8f6aea866f
4 changed files with 80 additions and 12 deletions

View file

@ -0,0 +1,57 @@
use std::fs;
use std::path::PathBuf;
use tauri::AppHandle;
// security: store the LCEOnline access token in a file under app_data_dir
// with 0600 permissions instead of localStorage. localStorage is readable
// by any JS in the webview origin (XSS exposure). the file is only
// readable by the user account that runs the launcher. (LCEL-08)
const TOKEN_FILE: &str = "session.json";
fn token_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no app data dir: {}", e))?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join(TOKEN_FILE))
}
#[tauri::command]
pub fn lceonline_token_store(app: AppHandle, token: String, username: String) -> Result<(), String> {
let path = token_path(&app)?;
let json = serde_json::json!({
"token": token,
"username": username,
})
.to_string();
fs::write(&path, json).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path).map_err(|e| e.to_string())?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&path, perms).map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub fn lceonline_token_load(app: AppHandle) -> Result<Option<serde_json::Value>, String> {
let path = token_path(&app)?;
if !path.exists() {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
Ok(Some(v))
}
#[tauri::command]
pub fn lceonline_token_clear(app: AppHandle) -> Result<(), String> {
let path = token_path(&app)?;
if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?;
}
Ok(())
}

View file

@ -4,6 +4,7 @@ pub mod dlc;
pub mod download;
pub mod file_dialogs;
pub mod game;
pub mod lceonline_token;
pub mod macos_setup;
pub mod plugins;
pub mod proxy_cmd;

View file

@ -118,6 +118,9 @@ pub fn run() {
plugins::list_directory,
plugins::create_plugin_dir,
plugins::remove_plugin_dir,
commands::lceonline_token::lceonline_token_store,
commands::lceonline_token::lceonline_token_load,
commands::lceonline_token::lceonline_token_clear,
])
.setup(|app| {
let app_handle = app.handle().clone();

View file

@ -1,9 +1,8 @@
// security: the access token currently lives in localStorage under
// SESSION_KEY, which is readable by any JS in the webview origin
// (XSS exposure). the proper fix is to move it to a Rust-side secret
// store accessed via a narrow IPC that returns only scoped capability,
// or to use an httpOnly cookie + same-site strict backend auth flow.
// (LCEL-08)
// security: the access token now lives in a file under app_data_dir with
// 0600 perms, accessed via narrow IPC commands (lceonline_token_store /
// _load / _clear). localStorage was readable by any JS in the webview
// origin (XSS exposure). (LCEL-08)
import { invoke } from "@tauri-apps/api/core";
const SESSION_KEY = "lceonline_session";
const SOCIAL_BASE_URL = "https://social.mclegacyedition.xyz";
const AUTH_BASE_URL = "https://auth.mclegacyedition.xyz"; //neo: yeah bro im hardcoding all three
@ -132,22 +131,30 @@ export class LceOnlineService {
}
}
private loadSession() {
private async loadSession() {
try {
const data = localStorage.getItem(SESSION_KEY);
const data = await invoke<{ token: string; username: string } | null>(
"lceonline_token_load"
);
if (data) {
this._session = JSON.parse(data);
this._session = {
accessToken: data.token,
account: { username: data.username, displayName: data.username },
};
}
} catch (e) {
console.warn("Failed to load LCE Online session", e);
}
}
private saveSession() {
private async saveSession() {
if (this._session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(this._session));
await invoke("lceonline_token_store", {
token: this._session.accessToken,
username: this._session.account.username,
});
} else {
localStorage.removeItem(SESSION_KEY);
await invoke("lceonline_token_clear");
}
}