feat: security hardening - restrict PTT capture keys, gate devtools to DEV, fix race conditions and DM sequencing

- Restrict PTT key capture to non-text keys only (function, navigation, mouse buttons) via allowlist (BUG-136)
- Gate DevTools button and F12/Ctrl+Shift+I shortcut behind import.meta.env.DEV
- Disable devtools Tauri feature in production (Cargo.toml default feature removed)
- Remove overly broad http:default capability, replace with scoped http:allow-fetch
- Set withGlobalTauri to false to avoid global __TAURI__ surface exposure
- Fix reconnect race: add abort checks after room creation, URL resolve, and connect (BUG-070)
- Fix ws.ts reconnect guard: bail out safely when config is null after disconnect
- Fix DM broadcast double-send and add monotonic seq + replay buffer support via sendSequencedToUsers
- Add seqMu mutex to serialize seq assignment across broadcastDM and deliverBroadcast paths
- Fix handleFreshConnect to unregister client and close connection on buildReady failure
- Add tests for PTT allowlist, ws reconnect config-null guard, livekit abort-after-connect, and DM sequencing
This commit is contained in:
J3vb
2026-04-04 09:08:01 +02:00
parent 92fdae0745
commit 321428c7f4
16 changed files with 262 additions and 44 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ tauri-build = { version = "2", features = [] }
tauri-typegen = "0.5"
[features]
default = ["devtools"]
default = []
devtools = ["tauri/devtools"]
[dependencies]
@@ -28,7 +28,6 @@
"notification:allow-notify",
"notification:allow-request-permission",
"notification:allow-is-permission-granted",
"http:default",
{
"identifier": "http:allow-fetch",
"allow": [
+60 -3
View File
@@ -25,6 +25,41 @@ static PTT_SHUTDOWN: AtomicBool = AtomicBool::new(false);
/// prevents duplicate thread spawns.
static PTT_THREAD: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
/// Returns true if a VK code is allowed for global capture in ptt_listen_for_key.
///
/// Security hardening (BUG-136): only non-text keys are capturable to reduce
/// misuse potential if the renderer is compromised.
fn is_allowed_ptt_capture_vk(vk: i32) -> bool {
// Explicitly reject modifier keys.
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
return false;
}
// Function keys F1-F24.
if (0x70..=0x87).contains(&vk) {
return true;
}
// Non-text navigation/control keys.
matches!(
vk,
0x1B | // Escape
0x20 | // Space
0x21 | // Page Up
0x22 | // Page Down
0x23 | // End
0x24 | // Home
0x25 | // Left
0x26 | // Up
0x27 | // Right
0x28 | // Down
0x2D | // Insert
0x2E | // Delete
0x05 | // Mouse X1
0x06 // Mouse X2
)
}
// ---------------------------------------------------------------------------
// Platform-specific key detection
// ---------------------------------------------------------------------------
@@ -371,7 +406,7 @@ pub async fn ptt_listen_for_key() -> i32 {
while std::time::Instant::now() < deadline {
for key in device_state.get_keys() {
let vk = linux::keycode_to_vk(&key);
if vk == 0 || linux::is_modifier_vk(vk) {
if vk == 0 || linux::is_modifier_vk(vk) || !is_allowed_ptt_capture_vk(vk) {
continue;
}
// Wait for key release (with its own timeout)
@@ -395,8 +430,8 @@ pub async fn ptt_listen_for_key() -> i32 {
while std::time::Instant::now() < deadline {
for vk in 1..=254i32 {
// Skip modifier keys
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
// Skip keys that are not explicitly allowed for capture.
if !is_allowed_ptt_capture_vk(vk) {
continue;
}
if is_key_down(vk) {
@@ -446,6 +481,28 @@ mod tests {
assert!(ptt_set_key(300).is_err());
}
#[test]
fn allowed_capture_vk_accepts_safe_non_text_keys() {
assert!(is_allowed_ptt_capture_vk(0x70)); // F1
assert!(is_allowed_ptt_capture_vk(0x7B)); // F12
assert!(is_allowed_ptt_capture_vk(0x25)); // Left arrow
assert!(is_allowed_ptt_capture_vk(0x2E)); // Delete
assert!(is_allowed_ptt_capture_vk(0x05)); // Mouse X1
assert!(is_allowed_ptt_capture_vk(0x06)); // Mouse X2
}
#[test]
fn allowed_capture_vk_rejects_text_and_modifier_keys() {
assert!(!is_allowed_ptt_capture_vk(0x41)); // A
assert!(!is_allowed_ptt_capture_vk(0x31)); // 1
assert!(!is_allowed_ptt_capture_vk(0x0D)); // Enter
assert!(!is_allowed_ptt_capture_vk(0x08)); // Backspace
assert!(!is_allowed_ptt_capture_vk(0x10)); // Shift
assert!(!is_allowed_ptt_capture_vk(0x11)); // Ctrl
assert!(!is_allowed_ptt_capture_vk(0x12)); // Alt
assert!(!is_allowed_ptt_capture_vk(0x5B)); // Meta
}
#[test]
fn ptt_get_key_reflects_set_key() {
ptt_set_key(0x41).unwrap();
@@ -22,7 +22,7 @@
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
}
],
"withGlobalTauri": true,
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com"
}
@@ -66,32 +66,34 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
const debugTitle = createElement("div", { class: "settings-section-title" }, "Debug");
section.appendChild(debugTitle);
// DevTools button row
const devtoolsRow = createElement("div", { class: "setting-row" });
const devtoolsInfo = createElement("div", {});
const devtoolsLabel = createElement("div", { class: "setting-label" }, "Open DevTools");
const devtoolsDesc = createElement(
"div",
{ class: "setting-desc" },
"Open the browser developer tools for debugging",
);
appendChildren(devtoolsInfo, devtoolsLabel, devtoolsDesc);
if (import.meta.env.DEV) {
// DevTools button row
const devtoolsRow = createElement("div", { class: "setting-row" });
const devtoolsInfo = createElement("div", {});
const devtoolsLabel = createElement("div", { class: "setting-label" }, "Open DevTools");
const devtoolsDesc = createElement(
"div",
{ class: "setting-desc" },
"Open the browser developer tools for debugging",
);
appendChildren(devtoolsInfo, devtoolsLabel, devtoolsDesc);
const devtoolsBtn = createElement("button", { class: "ac-btn" }, "Open DevTools");
devtoolsBtn.addEventListener(
"click",
() => {
void invoke("open_devtools").catch((err: unknown) => {
log.warn("DevTools not available", {
error: err instanceof Error ? err.message : String(err),
const devtoolsBtn = createElement("button", { class: "ac-btn" }, "Open DevTools");
devtoolsBtn.addEventListener(
"click",
() => {
void invoke("open_devtools").catch((err: unknown) => {
log.warn("DevTools not available", {
error: err instanceof Error ? err.message : String(err),
});
});
});
},
{ signal },
);
},
{ signal },
);
appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn);
section.appendChild(devtoolsRow);
appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn);
section.appendChild(devtoolsRow);
}
// ---- Storage & Cache section ------------------------------------------------
@@ -40,7 +40,7 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
() => {
if (capturing) return;
capturing = true;
pttValue.textContent = "Press any key...";
pttValue.textContent = "Press a supported key...";
pttValue.style.borderColor = "var(--accent)";
pttValue.style.color = "var(--accent)";
@@ -93,7 +93,7 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
{
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 16px 0; line-height: 1.4;",
},
"PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work.",
"PTT works globally and does not hijack the key. Capture supports function keys, navigation keys, and Mouse 4/5.",
);
section.appendChild(pttHint);
@@ -361,6 +361,18 @@ export class LiveKitSession {
}
try {
const newRoom = this.createRoom();
const cleanupAbortedReconnect = async (): Promise<void> => {
newRoom.removeAllListeners();
try {
await newRoom.disconnect();
} catch (disconnectErr) {
log.warn("Failed to disconnect room after reconnect abort", disconnectErr);
}
this._audioPipeline.setRoom(null);
this._audioElements.setRoom(null);
this._deviceManager.setRoom(null);
this._deviceManager.setAudioPipeline(null);
};
// Set state to reconnecting with the fresh room-less attempt info;
// the actual room appears in "connected" state after connect succeeds.
if (this._state.type === "reconnecting") {
@@ -370,10 +382,31 @@ export class LiveKitSession {
this._audioElements.setRoom(newRoom);
this._deviceManager.setRoom(newRoom);
this._deviceManager.setAudioPipeline(this._audioPipeline);
if (signal.aborted || this._currentChannelId !== channelId) {
log.info("Auto-reconnect aborted after room creation");
await cleanupAbortedReconnect();
return;
}
// eslint-disable-next-line no-await-in-loop -- sequential reconnect: resolve URL then connect
const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl);
if (signal.aborted || this._currentChannelId !== channelId) {
log.info("Auto-reconnect aborted before room connect");
await cleanupAbortedReconnect();
return;
}
// eslint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state
await newRoom.connect(resolvedUrl, token);
if (signal.aborted || this._currentChannelId !== channelId) {
log.info("Auto-reconnect aborted after room connect");
await cleanupAbortedReconnect();
return;
}
log.info("Auto-reconnect succeeded", { attempt, channelId, url: resolvedUrl });
// Transition to "connected" — this is the single atomic write.
this.setState({
+8 -1
View File
@@ -157,7 +157,13 @@ export function createWsClient() {
setState("reconnecting");
reconnectTimer = setTimeout(() => {
reconnectAttempt++;
void connect(config!);
const nextConfig = config;
if (!nextConfig) {
log.warn("Reconnect aborted: missing config");
setState("disconnected");
return;
}
void connect(nextConfig);
}, delay);
}
@@ -463,6 +469,7 @@ export function createWsClient() {
cleanupEventListeners();
void disconnectProxy();
setState("disconnected");
config = null;
// Reset lastSeq — disconnect() is only called for intentional close
// (logout). Automatic reconnects go through scheduleReconnect() which
// preserves lastSeq for server-side event replay.
+2 -2
View File
@@ -39,7 +39,7 @@ document.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
// F12 or Ctrl+Shift+I opens WebView2 DevTools.
// F12 or Ctrl+Shift+I opens WebView2 DevTools in development builds only.
// F5 and Ctrl+R are blocked to prevent accidental page reloads which cause
// ghost voice state (user appears in channel with no LiveKit connection).
document.addEventListener("keydown", (e) => {
@@ -47,7 +47,7 @@ document.addEventListener("keydown", (e) => {
e.preventDefault();
return;
}
if (e.key === "F12" || (e.ctrlKey && e.shiftKey && e.key === "I")) {
if (import.meta.env.DEV && (e.key === "F12" || (e.ctrlKey && e.shiftKey && e.key === "I"))) {
e.preventDefault();
void import("@tauri-apps/api/core").then(({ invoke }) => {
void invoke("open_devtools");
@@ -1789,6 +1789,37 @@ describe("LiveKitSession", () => {
expect(leaveVoiceChannel).toHaveBeenCalled();
});
it("cleans up reconnect room when signal aborts after connect resolves (BUG-070)", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
session.setServerHost("localhost:7880");
const ac = new AbortController();
mockRoom.connect.mockImplementationOnce(async () => {
ac.abort();
});
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(mockRoom.disconnect).toHaveBeenCalled();
expect((session as any)._state.type).not.toBe("connected");
});
});
describe("token refresh timer", () => {
+30
View File
@@ -1779,6 +1779,36 @@ describe("scheduleReconnect guard clauses", () => {
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
expect(reconnects).toHaveLength(0);
});
it("reconnect timer callback bails out safely when config is cleared", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Unexpected close schedules reconnect.
emitTauriEvent("ws-state", "closed");
// Simulate config being cleared before timer callback executes.
client.disconnect();
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(2_000);
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
expect(reconnects).toHaveLength(0);
expect(client.getState()).toBe("disconnected");
});
});
describe("cert-tofu non-mismatch statuses", () => {
+39
View File
@@ -176,6 +176,45 @@ func TestDM_ChatSend_ParticipantSuccess(t *testing.T) {
}
}
func TestDM_ChatSend_SequencedAndReplayBuffered(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "dm-seq-alice")
bob := seedMemberUser(t, database, "dm-seq-bob")
dmChID := seedDMChannel(t, database, alice.ID, bob.ID)
sendAlice := make(chan []byte, 128)
sendBob := make(chan []byte, 128)
cAlice := ws.NewTestClientWithUser(hub, alice, dmChID, sendAlice)
cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob)
hub.Register(cAlice)
hub.Register(cBob)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "m1"))
hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "m2"))
hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "m3"))
time.Sleep(120 * time.Millisecond)
bobMsgs := dmDrainAll(sendBob)
chat := dmFindMsgType(bobMsgs, "chat_message")
if chat == nil {
t.Fatal("Bob did not receive any DM chat_message")
}
if _, ok := chat["seq"]; !ok {
t.Fatal("DM chat_message is missing seq")
}
oldest := hub.ReplayBuffer().OldestSeq()
if oldest == 0 {
t.Fatal("replay buffer did not record DM events (oldest seq is 0)")
}
replayed := hub.ReplayBuffer().EventsSinceFiltered(oldest+1, map[int64]bool{dmChID: true})
if len(replayed) == 0 {
t.Fatal("expected DM replay events after oldest+1, got none")
}
}
func TestDM_ChatSend_NonParticipantForbidden(t *testing.T) {
hub, database := newHandlerHub(t)
alice := seedOwnerUser(t, database, "dm-forbid-alice")
+3 -6
View File
@@ -157,18 +157,15 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) {
}
// broadcastToDMParticipants sends a message to all participants of a DM channel
// using SendToUser for each participant. This bypasses the channel-subscription
// model used by BroadcastToChannel, which is correct for DMs since users may
// not be "focused" on the DM channel.
// while preserving DM semantics (delivery is by participant, not channel focus).
// Unlike broadcastToDMParticipantsExclude, this path is sequenced and replayable.
func (h *Hub) broadcastToDMParticipants(channelID int64, msg []byte) {
participantIDs, err := h.db.GetDMParticipantIDs(channelID)
if err != nil {
slog.Error("broadcastToDMParticipants GetDMParticipantIDs", "err", err, "channel_id", channelID)
return
}
for _, pid := range participantIDs {
h.SendToUser(pid, msg)
}
h.sendSequencedToUsers(channelID, participantIDs, msg)
}
// broadcastToDMParticipantsExclude sends a message to all participants of a DM
+2 -4
View File
@@ -209,10 +209,6 @@ func (h *Hub) broadcastChatMessage(c *Client, channelID int64, isDM bool, broadc
return
}
for _, pid := range participantIDs {
h.SendToUser(pid, broadcast)
}
for _, pid := range participantIDs {
if pid == c.userID {
continue
@@ -226,6 +222,8 @@ func (h *Hub) broadcastChatMessage(c *Client, channelID int64, isDM bool, broadc
h.SendToUser(pid, buildDMChannelOpen(channelID, c.user))
}
}
h.sendSequencedToUsers(channelID, participantIDs, broadcast)
}
// handleChatEdit processes a chat_edit message.
+22
View File
@@ -41,6 +41,7 @@ type Hub struct {
permChecker *permissions.Checker
seq uint64 // atomic monotonic sequence counter
seqMu syncutil.Mutex // serializes seq assignment + replay insertion + delivery order
replayBuf *EventRingBuffer // recent broadcast events for reconnection replay
broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel
@@ -439,6 +440,24 @@ func (h *Hub) SendToUser(userID int64, msg []byte) bool {
return c.trySendMsg(msg)
}
// sendSequencedToUsers stamps msg with a monotonic seq, stores it in the replay
// buffer under channelID, and fanouts the wrapped payload to the provided users.
func (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) {
h.seqMu.Lock()
defer h.seqMu.Unlock()
seq := h.nextSeq()
wrapped := wrapWithSeq(msg, seq)
// Store DM event for reconnect replay; filtering is channel-based and uses
// allowed channel IDs computed at auth time (including open DMs).
h.replayBuf.Push(seq, channelID, wrapped)
for _, userID := range userIDs {
h.SendToUser(userID, wrapped)
}
}
// ClientCount returns the number of currently registered clients (test helper).
func (h *Hub) ClientCount() int {
h.mu.RLock()
@@ -623,6 +642,9 @@ func (h *Hub) sweepStaleVoiceStates() {
// deliverBroadcast stamps bm.msg with a monotonic sequence number, stores it
// in the replay buffer, and sends it to the appropriate clients.
func (h *Hub) deliverBroadcast(bm broadcastMsg) {
h.seqMu.Lock()
defer h.seqMu.Unlock()
seq := h.nextSeq()
msg := wrapWithSeq(bm.msg, seq)
+3
View File
@@ -287,6 +287,9 @@ func (h *Hub) handleFreshConnect(
slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr)
_ = conn.Write(ctx, websocket.MessageText,
buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
h.unregisterNow(c)
_ = conn.Close(websocket.StatusInternalError, "failed to build ready payload")
return readyErr
}
if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil {