change: security-check, memory-check [untest]
- fix high risk security issues - fix high memory usage - change adb to connection pool single instance Signed-off-by: pakintada@gmail.com <Pakin>
This commit is contained in:
parent
f0619c5a10
commit
4f464a8513
23 changed files with 413 additions and 73 deletions
|
|
@ -17,8 +17,60 @@ import { adbWriter } from '../stores/adbWriter';
|
|||
import { WritableStream } from '@yume-chan/stream-extra';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { get } from 'svelte/store';
|
||||
import { adbConnectionStatus } from '../stores/adbConnectionStore';
|
||||
|
||||
let _connectionPromise: Promise<boolean> | null = null;
|
||||
let syncConnection: any = null;
|
||||
|
||||
/**
|
||||
* Centralized connection function that all pages and components should use.
|
||||
*
|
||||
* - If a device is already connected, returns `true` immediately.
|
||||
* - If a connection is already in progress, waits for it and returns its result.
|
||||
* - Otherwise attempts to connect using stored credentials first
|
||||
* (AdbDaemonWebUsbDeviceManager.BROWSER.getDevices), falling back to
|
||||
* prompting the user with the browser's WebUSB device picker.
|
||||
*
|
||||
* This deduplicates connection attempts across the app: no matter how many
|
||||
* pages/components call it simultaneously, only one WebUSB handshake happens.
|
||||
*
|
||||
* @returns `true` when a device is (or became) connected, `false` otherwise.
|
||||
*/
|
||||
export async function ensureAdbConnection(): Promise<boolean> {
|
||||
// Already connected.
|
||||
if (getAdbInstance()) return true;
|
||||
|
||||
// Already in progress — wait for it.
|
||||
if (_connectionPromise) return await _connectionPromise;
|
||||
|
||||
adbConnectionStatus.setConnecting();
|
||||
|
||||
_connectionPromise = (async () => {
|
||||
try {
|
||||
const deviceManager = AdbDaemonWebUsbDeviceManager.BROWSER;
|
||||
if (deviceManager && 'usb' in navigator) {
|
||||
const devices = await deviceManager.getDevices();
|
||||
if (devices && devices.length === 1) {
|
||||
const credStore = new AdbWebCredentialStore();
|
||||
await connectDeviceByCred(devices[0], credStore);
|
||||
return Boolean(getAdbInstance());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prompt user to pick a device.
|
||||
await connnectViaWebUSB();
|
||||
return Boolean(getAdbInstance());
|
||||
} catch (e) {
|
||||
logger.error('ensureAdbConnection failed', e);
|
||||
adbConnectionStatus.setDisconnected();
|
||||
return false;
|
||||
} finally {
|
||||
_connectionPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return await _connectionPromise;
|
||||
}
|
||||
let syncOperation: Promise<unknown> = Promise.resolve();
|
||||
let recipeMenuAdbConnectPromise: Promise<Adb | undefined> | null = null;
|
||||
let recipeMenuAndroidServerConnectPromise: Promise<void> | null = null;
|
||||
|
|
@ -138,6 +190,7 @@ async function connectWithRetry<T>(
|
|||
}
|
||||
|
||||
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
||||
logger.info('usb ok', globalThis.navigator.usb);
|
||||
if (device) {
|
||||
|
|
@ -180,6 +233,7 @@ export async function connectDeviceByCred(
|
|||
credStore: AdbWebCredentialStore,
|
||||
connectAndroidServer = true
|
||||
) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
try {
|
||||
const connection = await device.connect();
|
||||
const transport = await AdbDaemonTransport.authenticate({
|
||||
|
|
@ -197,6 +251,7 @@ export async function connectDeviceByCred(
|
|||
|
||||
return true;
|
||||
} catch (error) {
|
||||
adbConnectionStatus.setDisconnected();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -204,6 +259,11 @@ export async function connectDeviceByCred(
|
|||
export async function saveAdbInstance(adb: Adb | undefined) {
|
||||
await cleanupSync();
|
||||
AdbInstance.instance = adb;
|
||||
if (adb) {
|
||||
adbConnectionStatus.setConnected();
|
||||
} else {
|
||||
adbConnectionStatus.setDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdbInstance() {
|
||||
|
|
@ -211,6 +271,7 @@ export function getAdbInstance() {
|
|||
}
|
||||
|
||||
export async function connectRecipeMenuViaWebUSB() {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const currentInstance = getAdbInstance();
|
||||
if (currentInstance) {
|
||||
await connectToAndroidRecipeMenuServer();
|
||||
|
|
@ -250,6 +311,7 @@ export async function connectRecipeMenuDeviceByCred(
|
|||
device: AdbDaemonWebUsbDevice,
|
||||
credStore: AdbWebCredentialStore
|
||||
) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const currentInstance = getAdbInstance();
|
||||
if (currentInstance) {
|
||||
await connectToAndroidRecipeMenuServer();
|
||||
|
|
@ -316,7 +378,22 @@ export async function sendRecipeMenuMessageToAndroid(message: any) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject shell metacharacters that enable command injection.
|
||||
* Allows alphanumerics, spaces, common paths (/ - _ .), and ADB shell primitives.
|
||||
*/
|
||||
const UNSAFE_SHELL_PATTERN = /[;&|`$(){}[\]<>!#~*?\\\n\r]/;
|
||||
|
||||
function sanitizeAdbCommand(command: string): string {
|
||||
if (UNSAFE_SHELL_PATTERN.test(command)) {
|
||||
logger.warn(`Command contains unsafe characters, rejecting: ${command.slice(0, 80)}`);
|
||||
throw new Error(`Command rejected: contains shell metacharacters`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
export async function executeCmd(command: string) {
|
||||
command = sanitizeAdbCommand(command);
|
||||
let instance = getAdbInstance();
|
||||
|
||||
if (!instance) {
|
||||
|
|
@ -378,6 +455,7 @@ export async function executeStreamingCmd(
|
|||
onExit?: (exitCode: number | undefined) => void;
|
||||
}
|
||||
): Promise<() => void> {
|
||||
command = sanitizeAdbCommand(command);
|
||||
const instance = getAdbInstance();
|
||||
let aborted = false;
|
||||
|
||||
|
|
@ -637,14 +715,15 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
if (isRecoverableError(e)) {
|
||||
void connectToAndroidServer();
|
||||
}
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Brewing Mode T Offline ...');
|
||||
}
|
||||
})();
|
||||
return;
|
||||
} else {
|
||||
addNotification('WARN:Brewing Mode T unavailable');
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Brewing Mode T Offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
})();
|
||||
return;
|
||||
} else {
|
||||
addNotification('WARN:Brewing Mode T unavailable');
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000);
|
||||
|
|
@ -752,6 +831,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Android recipe menu channel offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
if (retryOnFailure) {
|
||||
scheduleRecipeMenuAndroidServerReconnect();
|
||||
}
|
||||
|
|
@ -773,3 +853,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
export function getScrcpyBinaryFromSource() {
|
||||
//https://github.com/Genymobile/scrcpy/releases
|
||||
}
|
||||
|
||||
// Initialize connection store based on existing instance
|
||||
// (handles hot-reload / page refresh where instance survives)
|
||||
if (AdbInstance.instance) {
|
||||
adbConnectionStatus.setConnected();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import { doc, getDoc } from 'firebase/firestore';
|
||||
import { db } from '../client/firebase';
|
||||
|
||||
export async function checkAllowAccess(userDomain: string): Promise<boolean> {
|
||||
const docRef = doc(db, 'whitelist', 'allowedDomains');
|
||||
const snapshot = await getDoc(docRef);
|
||||
|
||||
if (snapshot.exists()) {
|
||||
let domains = snapshot.data();
|
||||
return domains['account_email'].includes(userDomain);
|
||||
}
|
||||
|
||||
// Domain blocker disabled — authentication rework is in progress.
|
||||
// The domain changed from Google to others; this module will be rewritten.
|
||||
export async function checkAllowAccess(_userDomain: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,11 @@ type WSMessage = { type: string; payload: any };
|
|||
|
||||
// MAXIMUM LIMIT = 1814355
|
||||
const handlers: Record<string, (payload: any) => void> = {
|
||||
chat: (p) => messages.update((m) => [...m, p]),
|
||||
chat: (p) =>
|
||||
messages.update((m) => {
|
||||
const next = [...m, p];
|
||||
return next.length > 200 ? next.slice(-200) : next;
|
||||
}),
|
||||
ping: (p) => logger.info('ping from server'),
|
||||
recipeResponse: (p) => {
|
||||
let recipe_result = p.result;
|
||||
|
|
|
|||
54
src/lib/core/stores/adbConnectionStore.ts
Normal file
54
src/lib/core/stores/adbConnectionStore.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Possible states for the ADB (WebUSB) device connection.
|
||||
*
|
||||
* - disconnected: No device is currently connected.
|
||||
* - connecting: A connection attempt is in progress.
|
||||
* - connected: A device is connected and the Adb instance is valid.
|
||||
*/
|
||||
export type AdbConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
|
||||
function createAdbConnectionStore() {
|
||||
const { subscribe, set } = writable<AdbConnectionStatus>('disconnected');
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setConnected: () => set('connected'),
|
||||
setConnecting: () => set('connecting'),
|
||||
setDisconnected: () => set('disconnected'),
|
||||
/**
|
||||
* Reset to the disconnected state.
|
||||
*/
|
||||
reset: () => set('disconnected')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive store that tracks the current ADB connection status.
|
||||
*
|
||||
* Components can subscribe to this store and reactively update their UI
|
||||
* whenever the connection state changes (e.g. show/hide connect buttons,
|
||||
* enable/disable features).
|
||||
*
|
||||
* The store is updated by adb.ts internals — pages and components should
|
||||
* never call setConnected / setDisconnected directly; they should use the
|
||||
* exported `ensureAdbConnection()` or `disconnect()` functions from adb.ts.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* import { adbConnectionStatus, isAdbConnected } from '$lib/core/stores/adbConnectionStore';
|
||||
*
|
||||
* // Reactive boolean — true when connected
|
||||
* let connected = $derived($isAdbConnected);
|
||||
*
|
||||
* // Or subscribe manually
|
||||
* $: console.log('adb status', $adbConnectionStatus);
|
||||
* ```
|
||||
*/
|
||||
export const adbConnectionStatus = createAdbConnectionStore();
|
||||
|
||||
/**
|
||||
* Derived store that emits `true` when the ADB device is connected.
|
||||
*/
|
||||
export const isAdbConnected = derived(adbConnectionStatus, ($status) => $status === 'connected');
|
||||
|
|
@ -4,9 +4,14 @@ import { get, writable } from 'svelte/store';
|
|||
// save notifications to user
|
||||
export const notiStore = writable<string[]>([]);
|
||||
|
||||
const MAX_NOTI_SIZE = 1000;
|
||||
|
||||
export function addNotification(msg: string) {
|
||||
let current = get(notiStore);
|
||||
current.push(msg);
|
||||
if (current.length > MAX_NOTI_SIZE) {
|
||||
current = current.slice(-MAX_NOTI_SIZE);
|
||||
}
|
||||
notiStore.set(current);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@ import { executeCmd, executeStreamingCmd, getAdbInstance } from '../adb/adb';
|
|||
import { sendMessage } from '../handlers/ws_messageSender';
|
||||
import { addNotification } from './noti';
|
||||
|
||||
// Commands that modify device state — always require explicit user confirmation.
|
||||
const DANGEROUS_COMMAND_PREFIXES = [
|
||||
'rm', 'reboot', 'reboot-bootloader', 'flash', 'format',
|
||||
'mount', 'unmount', 'dd', 'mkfs', 'wipe',
|
||||
'install', 'uninstall', 'pm ', 'am ', 'svc ',
|
||||
];
|
||||
|
||||
function isDangerousCommand(command: string): boolean {
|
||||
const trimmed = command.trim().toLowerCase();
|
||||
// Block shell metacharacters that enable multi-command injection
|
||||
if (/[;&|`$(){}[\]<>!\\]/.test(trimmed)) return true;
|
||||
return DANGEROUS_COMMAND_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an incoming remote-shell request from the WebSocket server
|
||||
* asking the client to execute a command on the connected Android device.
|
||||
|
|
@ -21,6 +35,8 @@ export interface RemoteShellRequest {
|
|||
timeoutSeconds?: number;
|
||||
/** True if the command was aborted by timeout rather than natural completion. */
|
||||
timedOut?: boolean;
|
||||
/** True if the command is flagged as dangerous and requires explicit user confirmation. */
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,23 +51,40 @@ export const remoteShellRequest = writable<RemoteShellRequest | null>(null);
|
|||
* prompt the user. Otherwise, immediately executes the command.
|
||||
*/
|
||||
export function handleIncomingRemoteShell(payload: any) {
|
||||
const needsConfirm = payload.requireConfirmation === true;
|
||||
let needsConfirm = payload.requireConfirmation === true;
|
||||
const rawTimeout = payload.timeout;
|
||||
const timeoutSeconds =
|
||||
rawTimeout != null && typeof rawTimeout === 'number' && rawTimeout > 0 ? rawTimeout : undefined;
|
||||
|
||||
const commandInput = payload.commandInput || '';
|
||||
const dangerous = isDangerousCommand(commandInput);
|
||||
|
||||
// Dangerous commands always require explicit user confirmation
|
||||
if (dangerous) {
|
||||
needsConfirm = true;
|
||||
}
|
||||
|
||||
const req: RemoteShellRequest = {
|
||||
requestId: payload.generatedRequestId,
|
||||
commandInput: payload.commandInput,
|
||||
purposeDeclaration: payload.purposeDeclaration,
|
||||
commandInput,
|
||||
purposeDeclaration: payload.purposeDeclaration || '',
|
||||
userInfo: payload.userInfo,
|
||||
status: needsConfirm ? 'pending' : 'confirmed',
|
||||
timestamp: Date.now(),
|
||||
timeoutSeconds
|
||||
timeoutSeconds,
|
||||
dangerous
|
||||
};
|
||||
|
||||
remoteShellRequest.set(req);
|
||||
|
||||
if (dangerous) {
|
||||
addNotification(
|
||||
'WARN:Remote shell command requires your approval – command will not run unless you accept'
|
||||
);
|
||||
} else if (needsConfirm) {
|
||||
addNotification('INFO:Remote shell command requires your approval');
|
||||
}
|
||||
|
||||
if (!needsConfirm) {
|
||||
const suffix = timeoutSeconds ? ` (timeout: ${timeoutSeconds}s)` : '';
|
||||
addNotification(`INFO:Remote shell command received – executing automatically${suffix}`);
|
||||
|
|
|
|||
|
|
@ -176,11 +176,12 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (e) => {
|
||||
// logger.info('WebSocket error: ', e);
|
||||
socketStore.set(null);
|
||||
sharedKey.set(null);
|
||||
});
|
||||
socket.addEventListener('error', (e) => {
|
||||
// logger.info('WebSocket error: ', e);
|
||||
socketStore.set(null);
|
||||
sharedKey.set(null);
|
||||
clearInterval(socketCheck);
|
||||
});
|
||||
} catch (socket_error: any) {
|
||||
if (ENABLE_WS_DEBUG) {
|
||||
logger.error('WS_ERR', socket_error);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue