Skip to content

Commit c219cdc

Browse files
JSKittyclaude
andcommitted
fix: Security hardening and bug fixes for v0.3.0
Security: - Fix PIVX wallet panic on malformed txid from explorer API - Add path traversal prevention in Mini App ZIP extraction - Zero-window Tauri API restriction for Mini Apps (property interception) - Add android:allowBackup="false" to prevent sensitive data backup - Remove HTTP scheme from Android deep links (HTTPS only) - Add XSS prevention for image src injection in file previews Bug fixes: - Fix "Unknown" reply author in group chats (set npub on outgoing messages) - Fix duplicate system events for Leave Group messages - Prevent group creator from leaving unless they're the only member UI: - Add x-user icon and use it for Leave Group button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a33bc65 commit c219cdc

10 files changed

Lines changed: 120 additions & 60 deletions

File tree

src-tauri/gen/android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
android:label="@string/app_name"
1717
android:theme="@style/Theme.vector"
1818
android:usesCleartextTraffic="${usesCleartextTraffic}"
19+
android:allowBackup="false"
1920
android:hardwareAccelerated="true">
2021
<activity
2122
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
@@ -46,7 +47,6 @@
4647
<category android:name="android.intent.category.DEFAULT" />
4748
<category android:name="android.intent.category.BROWSABLE" />
4849
<data android:scheme="https" />
49-
<data android:scheme="http" />
5050
<data android:host="vectorapp.io" />
5151

5252

src-tauri/src/message.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,11 @@ pub async fn message(receiver: String, content: String, replied_to: String, file
459459
.unwrap();
460460
// Create persistent pending_id that will live for the entire function
461461
let pending_id = Arc::new(String::from("pending-") + &current_time.as_nanos().to_string());
462+
// Grab our pubkey first (needed for npub in group chats)
463+
let client = NOSTR_CLIENT.get().expect("Nostr client not initialized");
464+
let signer = client.signer().await.unwrap();
465+
let my_public_key = signer.get_public_key().await.unwrap();
466+
462467
let msg = Message {
463468
id: pending_id.as_ref().clone(),
464469
content,
@@ -473,15 +478,11 @@ pub async fn message(receiver: String, content: String, replied_to: String, file
473478
pending: true,
474479
failed: false,
475480
mine: true,
476-
npub: None, // Pending messages don't need npub (they're always mine)
481+
npub: my_public_key.to_bech32().ok(), // Needed for group chats so replies show correct author
477482
wrapper_event_id: None, // Will be set when message is sent
478483
edited: false,
479484
edit_history: None,
480485
};
481-
// Grab our pubkey first
482-
let client = NOSTR_CLIENT.get().expect("Nostr client not initialized");
483-
let signer = client.signer().await.unwrap();
484-
let my_public_key = signer.get_public_key().await.unwrap();
485486

486487
// Detect if this is a group chat or DM
487488
// First check if a chat already exists and use its type

src-tauri/src/miniapps/commands.rs

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -395,51 +395,62 @@ try {
395395
})();
396396
397397
// Wrap Tauri's __TAURI__ API to restrict access to only allowed commands
398-
// We need to wait for Tauri to initialize first
399-
try {
400-
const setupTauriRestrictions = () => {
401-
if (!window.__TAURI__ || !window.__TAURI__.core) {
402-
// Tauri not ready yet, try again
403-
setTimeout(setupTauriRestrictions, 10);
404-
return;
405-
}
398+
// Uses property interception to ensure ZERO timing window for bypass
399+
(function() {
400+
'use strict';
406401
407-
// Wrap the core invoke to reject all calls except our allowed ones
408-
const originalInvoke = window.__TAURI__.core.invoke;
409-
const originalChannel = window.__TAURI__.core.Channel;
410-
411-
window.__TAURI__.core.invoke = async (cmd, args) => {
412-
// Allow our miniapp commands
413-
const allowedCommands = [
414-
'miniapp_get_updates',
415-
'miniapp_send_update',
416-
'miniapp_join_realtime_channel',
417-
'miniapp_send_realtime_data',
418-
'miniapp_leave_realtime_channel',
419-
'miniapp_add_realtime_peer',
420-
'miniapp_get_realtime_node_addr',
421-
'miniapp_get_granted_permissions_for_window'
422-
];
402+
const allowedCommands = [
403+
'miniapp_get_updates',
404+
'miniapp_send_update',
405+
'miniapp_join_realtime_channel',
406+
'miniapp_send_realtime_data',
407+
'miniapp_leave_realtime_channel',
408+
'miniapp_add_realtime_peer',
409+
'miniapp_get_realtime_node_addr',
410+
'miniapp_get_granted_permissions_for_window'
411+
];
412+
413+
function wrapTauriApi(tauriObj) {
414+
if (!tauriObj || !tauriObj.core) return tauriObj;
415+
416+
const originalInvoke = tauriObj.core.invoke;
417+
const originalChannel = tauriObj.core.Channel;
418+
419+
tauriObj.core.invoke = async (cmd, args) => {
423420
if (allowedCommands.includes(cmd)) {
424-
return originalInvoke.call(window.__TAURI__.core, cmd, args);
421+
return originalInvoke.call(tauriObj.core, cmd, args);
425422
}
426423
console.warn('Mini App tried to invoke blocked Tauri command:', cmd);
427424
throw new Error('Tauri command not available in Mini Apps: ' + cmd);
428425
};
429426
430-
// Ensure Channel class is still available (needed for realtime)
427+
// Preserve Channel class (needed for realtime)
431428
if (originalChannel) {
432-
window.__TAURI__.core.Channel = originalChannel;
429+
tauriObj.core.Channel = originalChannel;
433430
}
434431
435432
console.log("[MiniApp] Tauri restrictions applied");
436-
};
433+
return tauriObj;
434+
}
437435
438-
// Start checking for Tauri
439-
setupTauriRestrictions();
440-
} catch (e) {
441-
console.warn("Failed to setup Tauri restrictions:", e);
442-
}
436+
// If __TAURI__ already exists, wrap it immediately
437+
if (window.__TAURI__) {
438+
wrapTauriApi(window.__TAURI__);
439+
}
440+
441+
// Intercept any future assignment to __TAURI__ (zero timing window)
442+
let _tauriValue = window.__TAURI__;
443+
Object.defineProperty(window, '__TAURI__', {
444+
get() {
445+
return _tauriValue;
446+
},
447+
set(newValue) {
448+
_tauriValue = wrapTauriApi(newValue);
449+
},
450+
configurable: false, // Prevent re-definition
451+
enumerable: true
452+
});
453+
})();
443454
"#;
444455

445456
/// Get the base URL for Mini Apps based on platform

src-tauri/src/miniapps/state.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,14 @@ impl MiniAppPackage {
148148
pub fn get_file(&self, path: &str) -> Result<Vec<u8>, Error> {
149149
let file = std::fs::File::open(&self.path)?;
150150
let mut archive = zip::ZipArchive::new(file)?;
151-
152-
// Normalize path (remove leading slash)
151+
152+
// Normalize path (remove leading slash) and prevent path traversal
153153
let normalized_path = path.trim_start_matches('/');
154+
155+
// Reject paths containing directory traversal sequences
156+
if normalized_path.contains("..") || normalized_path.contains("\\..") {
157+
return Err(Error::FileNotFound(format!("Invalid path: {}", path)));
158+
}
154159

155160
let mut zip_file = archive.by_name(normalized_path)
156161
.map_err(|_| Error::FileNotFound(path.to_string()))?;

src-tauri/src/pivx.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,8 @@ pub async fn build_sweep_transaction(
558558
preimage.push(utxos.len() as u8); // Input count
559559

560560
for (j, other_utxo) in utxos.iter().enumerate() {
561-
let other_txid = hex::decode(&other_utxo.txid).unwrap();
561+
let other_txid = hex::decode(&other_utxo.txid)
562+
.map_err(|e| format!("Invalid txid hex '{}': {}", &other_utxo.txid, e))?;
562563
preimage.extend(other_txid.iter().rev());
563564
preimage.extend_from_slice(&other_utxo.vout.to_le_bytes());
564565

@@ -601,7 +602,8 @@ pub async fn build_sweep_transaction(
601602
let script_sig_len = 1 + sig_bytes.len() + 1 + pubkey_bytes.len();
602603

603604
// Write input to signed tx
604-
let txid_bytes = hex::decode(&utxo.txid).unwrap();
605+
let txid_bytes = hex::decode(&utxo.txid)
606+
.map_err(|e| format!("Invalid txid hex '{}': {}", &utxo.txid, e))?;
605607
signed_tx.extend(txid_bytes.iter().rev());
606608
signed_tx.extend_from_slice(&utxo.vout.to_le_bytes());
607609

src/icons/x-user.svg

Lines changed: 3 additions & 0 deletions
Loading

src/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,9 @@ <h3 id="group-overview-secondary-name" class="chat-contact-with-status btn"></h3
556556
<span class="icon icon-add-user" style="width: 16px; height: 16px; flex-shrink: 0; position: relative; background-color: var(--icon-color-primary);"></span>
557557
<span style="color: white;">Invite Member</span>
558558
</button>
559-
<button id="group-leave-btn" class="btn cancel-btn btn-bounce" style="background-color: transparent; display: none; align-items: center; gap: 6px;">
560-
<span class="icon icon-trash" style="width: 16px; height: 16px; flex-shrink: 0; position: relative; background-color: var(--icon-color-danger);"></span>
561-
<span style="color: var(--color-danger);">Leave Group</span>
559+
<button id="group-leave-btn" class="btn cancel-btn btn-bounce" style="display: none; align-items: center; gap: 6px;">
560+
<span class="icon icon-x-user" style="width: 16px; height: 16px; flex-shrink: 0; position: relative; background-color: #ff2ea9;"></span>
561+
<span style="color: white;">Leave Group</span>
562562
</button>
563563
</div>
564564
<div class="emoji-search-container" style="padding: 10px 0px;">

src/js/file-preview.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ const SUPPORTED_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'tiff',
2222
// Video extensions supported for preview (mp4, webm, mov - except on Linux)
2323
const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov'];
2424

25+
/**
26+
* Validate that a string is a safe image source (data URL or blob URL)
27+
* Prevents XSS via malicious src injection (e.g., javascript: protocol)
28+
* @param {string} src - The source string to validate
29+
* @returns {string|null} The validated src or null if invalid
30+
*/
31+
function validateImageSrc(src) {
32+
if (!src || typeof src !== 'string') return null;
33+
// Allow data URLs for images and blob URLs only
34+
if (src.startsWith('data:image/') || src.startsWith('blob:')) {
35+
return src;
36+
}
37+
console.warn('[file-preview] Rejected invalid image src:', src.substring(0, 50));
38+
return null;
39+
}
40+
2541
/**
2642
* Format file size in human-readable format
2743
* @param {number} bytes - File size in bytes
@@ -128,11 +144,12 @@ function isMiniAppExtension(ext) {
128144
* @param {string} fileName - Fallback file name if no Mini App info
129145
*/
130146
function displayMiniAppPreview(contentArea, miniAppInfo, fileName) {
131-
if (miniAppInfo && miniAppInfo.icon_data) {
147+
const validatedIcon = miniAppInfo ? validateImageSrc(miniAppInfo.icon_data) : null;
148+
if (validatedIcon) {
132149
// Show Mini App icon
133150
contentArea.innerHTML = `
134151
<div class="file-preview-image-container file-preview-miniapp">
135-
<img src="${miniAppInfo.icon_data}" class="file-preview-image file-preview-miniapp-icon" alt="${miniAppInfo.name || 'Mini App'}">
152+
<img src="${validatedIcon}" class="file-preview-image file-preview-miniapp-icon" alt="${escapeHtml(miniAppInfo.name || 'Mini App')}">
136153
</div>
137154
`;
138155
} else {
@@ -310,11 +327,12 @@ async function openFilePreview(filepath, receiver, replyRef = '') {
310327
// Show image preview
311328
const isAndroid = typeof platformFeatures !== 'undefined' && platformFeatures.os === 'android';
312329

313-
if (isAndroid && androidPreview) {
330+
const validatedAndroidPreview = validateImageSrc(androidPreview);
331+
if (isAndroid && validatedAndroidPreview) {
314332
// On Android, use the base64 preview we already got from cache_android_file
315333
contentArea.innerHTML = `
316334
<div class="file-preview-image-container">
317-
<img src="${androidPreview}" class="file-preview-image" alt="Preview">
335+
<img src="${validatedAndroidPreview}" class="file-preview-image" alt="Preview">
318336
</div>
319337
`;
320338
} else if (isAndroid) {
@@ -729,10 +747,11 @@ async function openFilePreviewWithBytes(bytes, fileName, ext, fileSize, receiver
729747
optionsArea.innerHTML = '';
730748
} else if (isImage) {
731749
// Use the preview from Rust (already a base64 data URL)
732-
if (preview) {
750+
const validatedPreview = validateImageSrc(preview);
751+
if (validatedPreview) {
733752
contentArea.innerHTML = `
734753
<div class="file-preview-image-container">
735-
<img src="${preview}" class="file-preview-image" alt="Preview">
754+
<img src="${validatedPreview}" class="file-preview-image" alt="Preview">
736755
</div>
737756
`;
738757
} else {
@@ -890,10 +909,11 @@ async function startFileObjectCacheAndPreview(file, fileName, ext, contentArea,
890909
});
891910

892911
// Display preview
893-
if (result.preview) {
912+
const validatedResultPreview = validateImageSrc(result.preview);
913+
if (validatedResultPreview) {
894914
contentArea.innerHTML = `
895915
<div class="file-preview-image-container">
896-
<img src="${result.preview}" class="file-preview-image" alt="Preview">
916+
<img src="${validatedResultPreview}" class="file-preview-image" alt="Preview">
897917
</div>
898918
`;
899919
} else {

src/main.js

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5054,11 +5054,9 @@ async function setupRustListeners() {
50545054
}
50555055
};
50565056

5057-
// Add to chat messages
5058-
if (chat) {
5059-
chat.messages.push(systemMsg);
5060-
eventCache.addEvent(conversation_id, systemMsg);
5061-
}
5057+
// Add to chat messages via cache (handles deduplication)
5058+
// Note: chat.messages and cache share the same array reference, so only use cache
5059+
eventCache.addEvent(conversation_id, systemMsg);
50625060

50635061
// If this chat is currently open, render the system event
50645062
if (strOpenChat === conversation_id && domChatMessages) {
@@ -9507,8 +9505,24 @@ async function renderGroupOverview(chat) {
95079505
// Leave Group button
95089506
domGroupLeaveBtn.style.display = 'flex';
95099507
domGroupLeaveBtn.onclick = async () => {
9510-
// Confirm before leaving using popupConfirm
95119508
const groupName = chat.metadata?.custom_fields?.name || `Group ${chat.id.substring(0, 10)}...`;
9509+
9510+
// Check if user is the group creator
9511+
const isCreator = myProfile && myProfile.id === chat.metadata?.creator_pubkey;
9512+
9513+
// Creators cannot leave unless they are the only member
9514+
if (isCreator && memberCount > 1) {
9515+
await popupConfirm(
9516+
'Cannot Leave Group',
9517+
'You are the creator of this group. You must remove all other members before you can leave.<br><br>Please kick all members first, then you can leave the group.',
9518+
true, // Notice only, no cancel button
9519+
'', // No input
9520+
'vector_warning.svg'
9521+
);
9522+
return;
9523+
}
9524+
9525+
// Confirm before leaving using popupConfirm
95129526
const confirmed = await popupConfirm(
95139527
'Leave Group',
95149528
`Are you sure you want to leave "<b>${groupName}</b>"?<br><br>You will need to be re-invited to rejoin.`,

src/styles.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3726,6 +3726,10 @@ select:focus::-ms-value {
37263726
mask-size: contain;
37273727
}
37283728

3729+
.icon-x-user {
3730+
mask-image: url("./icons/x-user.svg");
3731+
}
3732+
37293733
/* For downloaded models - make it more prominent */
37303734
.btn-delete-model.downloaded {
37313735
opacity: 0.8;

0 commit comments

Comments
 (0)