diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index b2ef389..6ab7dc4 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -186,7 +186,59 @@ export class Browser { await page.addInitScript(antiDetectScript) await page.addInitScript(timezoneScript) - // Virtual Authenticator support removed — no CDP WebAuthn setup performed here + // CRITICAL: Block WebAuthn API calls to prevent passkey dialogs + await page.addInitScript(() => { + // Override navigator.credentials to block passkey requests + if (window.navigator.credentials) { + // Block credential creation (passkey enrollment) + window.navigator.credentials.create = async function (...args: any[]) { + console.log('[MRS] Blocked WebAuthn credential.create() call') + // Reject with NotAllowedError (user cancelled) + throw new DOMException('The operation either timed out or was not allowed.', 'NotAllowedError') + } + + // Block credential retrieval (passkey authentication) + window.navigator.credentials.get = async function (...args: any[]) { + console.log('[MRS] Blocked WebAuthn credential.get() call') + // Reject with NotAllowedError (user cancelled) + throw new DOMException('The operation either timed out or was not allowed.', 'NotAllowedError') + } + } + + // Also remove PublicKeyCredential if it exists + if (window.PublicKeyCredential) { + // @ts-ignore - Override isUserVerifyingPlatformAuthenticatorAvailable + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = async () => false + // @ts-ignore - Override isConditionalMediationAvailable + window.PublicKeyCredential.isConditionalMediationAvailable = async () => false + } + }) + + // CRITICAL: Disable WebAuthn popups using Virtual Authenticator + // This prevents native "Choose where to save your passkey" dialogs + try { + const client = await page.context().newCDPSession(page) + + // Enable WebAuthn and add a virtual authenticator that auto-rejects + await client.send('WebAuthn.enable') + + // Add virtual authenticator with settings that prevent UI prompts + await client.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: false, // No resident keys = no passkey storage + hasUserVerification: false, // No biometric/PIN verification + isUserVerified: false, // Always fail verification + automaticPresenceSimulation: false // No automatic approval + } + }) + + this.bot.log(this.bot.isMobile, 'BROWSER', 'WebAuthn Virtual Authenticator enabled (passkey dialogs disabled)') + } catch (cdpError) { + // Non-critical: CDP might not be available on all browsers + this.bot.log(this.bot.isMobile, 'BROWSER', `WebAuthn setup skipped: ${cdpError instanceof Error ? cdpError.message : String(cdpError)}`, 'warn') + } // IMPROVED: Use crypto-secure random for viewport sizes const { secureRandomInt } = await import('../util/security/SecureRandom') diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 2452caf..a165305 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -243,6 +243,21 @@ export class BrowserFunc { const currentURL = new URL(target.url()) try { + // Check if account is suspended BEFORE attempting to fetch dashboard data + const suspendedError = await target.locator('#rewards-user-suspended-error, #fraudErrorBody').first().isVisible({ timeout: 1000 }).catch(() => false) + if (suspendedError) { + this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', '⛔ Account suspension detected, checking details...', 'error') + + // Use SecurityDetector to handle suspension + const { SecurityDetector } = await import('../functions/login/SecurityDetector') + const { SecurityUtils } = await import('../functions/login/SecurityUtils') + const securityUtils = new SecurityUtils(this.bot) + const securityDetector = new SecurityDetector(this.bot, securityUtils) + + await securityDetector.checkAccountSuspended(target) + throw new Error('Account suspended by Microsoft Rewards - account disabled in accounts.jsonc') + } + // Should never happen since tasks are opened in a new tab! if (currentURL.hostname !== dashboardURL.hostname) { this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page') diff --git a/src/functions/Login.ts b/src/functions/Login.ts index d39be61..517a51e 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -109,6 +109,9 @@ export class Login { this.recoveryHandler = new RecoveryHandler(bot, this.securityUtils) this.securityDetector = new SecurityDetector(bot, this.securityUtils) + // Connect PasskeyHandler to TotpHandler for post-TOTP passkey handling + this.totpHandler.setPasskeyHandler(this.passkeyHandler) + this.securityUtils.cleanupCompromisedInterval() } @@ -861,6 +864,12 @@ export class Login { const portalSelector = await this.waitForRewardsRoot(page, DEFAULT_TIMEOUTS.portalWaitMs) if (!portalSelector) { + // Before trying fallback, check if account is suspended/banned + const isSuspended = await this.securityDetector.checkAccountSuspended(page) + if (isSuspended) { + throw new Error('Account suspended by Microsoft Rewards - disabled in accounts.jsonc') + } + this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal not found, trying goHome() fallback...', 'warn') try { diff --git a/src/functions/login/PasskeyHandler.ts b/src/functions/login/PasskeyHandler.ts index e079919..ef7cb93 100644 --- a/src/functions/login/PasskeyHandler.ts +++ b/src/functions/login/PasskeyHandler.ts @@ -14,7 +14,12 @@ export class PasskeyHandler { passkeyPrimary: 'button[data-testid="primaryButton"]', passkeyTitle: '[data-testid="title"]', kmsiVideo: '[data-testid="kmsiVideo"]', - biometricVideo: '[data-testid="biometricVideo"]' + biometricVideo: '[data-testid="biometricVideo"]', + // QR Code Passkey dialog specific selectors + qrCodeDialog: 'div[role="dialog"]', + qrCodeImage: 'img[alt*="QR"], canvas[aria-label*="QR"], div[class*="qr"]', + backButton: 'button:has-text("Back")', + cancelButton: 'button:has-text("Cancel")' } as const constructor(bot: MicrosoftRewardsBot) { @@ -37,6 +42,13 @@ export class PasskeyHandler { public async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') { let did = false + // Priority 0: Handle QR Code Passkey dialog (appears after TOTP) + const qrCodeHandled = await this.handleQrCodePasskeyDialog(page) + if (qrCodeHandled) { + did = true + this.logPasskeyOnce('QR code passkey dialog') + } + // Early exit for passkey creation flows (common on mobile): hit cancel/skip if present const currentUrl = page.url() if (/fido\/create|passkey/i.test(currentUrl)) { @@ -234,4 +246,89 @@ export class PasskeyHandler { this.passkeyHandled = true this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`) } + + /** + * Handle QR Code Passkey dialog that appears after TOTP authentication + * This dialog is a modal that blocks interaction with the page + */ + private async handleQrCodePasskeyDialog(page: Page): Promise { + try { + // Method 1: Check for specific text content indicating QR code dialog + const qrCodeTextVisible = await page.locator('text=/use your phone or tablet|scan this QR code|passkeys/i') + .first() + .isVisible({ timeout: 800 }) + .catch(() => false) + + if (!qrCodeTextVisible) return false + + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Detected QR code passkey dialog, attempting dismissal') + + // Method 2: Try keyboard ESC first (works for many dialogs) + await page.keyboard.press('Escape').catch(() => { }) + await this.bot.utils.wait(300) + + // Method 3: Check if dialog still visible after ESC + const stillVisible = await page.locator('text=/use your phone or tablet|scan this QR code/i') + .first() + .isVisible({ timeout: 500 }) + .catch(() => false) + + if (!stillVisible) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'QR code dialog dismissed via ESC') + return true + } + + // Method 4: Try clicking Back or Cancel buttons + const dismissed = await this.clickFirstVisible(page, [ + PasskeyHandler.SELECTORS.backButton, + PasskeyHandler.SELECTORS.cancelButton, + 'button:has-text("Retour")', // French + 'button:has-text("Annuler")', // French + 'button:has-text("No thanks")', + 'button:has-text("Maybe later")', + '[data-testid="secondaryButton"]', + 'button[class*="secondary"]' + ], 500) + + if (dismissed) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'QR code dialog dismissed via button click') + return true + } + + // Method 5: JavaScript injection to close dialog + const jsResult = await page.evaluate(() => { + // Find dialog by role + const dialogs = document.querySelectorAll('[role="dialog"]') + for (const dialog of dialogs) { + const text = dialog.textContent || '' + if (/passkey|qr code|phone or tablet/i.test(text)) { + // Try to find and click cancel/back button + const buttons = dialog.querySelectorAll('button') + for (const btn of buttons) { + const btnText = (btn.textContent || '').toLowerCase() + if (/back|cancel|retour|annuler|no thanks|maybe later|skip/i.test(btnText)) { + btn.click() + return true + } + } + // If no button found, try to remove dialog from DOM + dialog.remove() + return true + } + } + return false + }).catch(() => false) + + if (jsResult) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'QR code dialog dismissed via JavaScript injection') + return true + } + + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Failed to dismiss QR code dialog with all methods', 'warn') + return false + } catch (error) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `QR code dialog handler error: ${error}`, 'error') + return false + } + } } diff --git a/src/functions/login/SecurityDetector.ts b/src/functions/login/SecurityDetector.ts index 7f80ca7..edd21f9 100644 --- a/src/functions/login/SecurityDetector.ts +++ b/src/functions/login/SecurityDetector.ts @@ -63,4 +63,79 @@ export class SecurityDetector { throw new Error('Account locked by Microsoft - please review account status') } } + + /** + * Check if account is suspended/banned and disable it in accounts.jsonc + * @param page Playwright page + * @returns True if account is suspended + */ + public async checkAccountSuspended(page: Page): Promise { + try { + // Check for suspension page elements + const suspendedSelectors = [ + '#rewards-user-suspended-error', + '#fraudErrorBody', + '#suspendedAccountHeader' + ] + + for (const selector of suspendedSelectors) { + const element = await page.waitForSelector(selector, { timeout: 800 }).catch(() => null) + if (element) { + const email = this.bot.currentAccountEmail || 'unknown' + this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `⛔ Account ${email} has been SUSPENDED by Microsoft`, 'error') + + // Get suspension details from page + const headerText = await page.locator('#suspendedAccountHeader').textContent().catch(() => '') + const summaryText = await page.locator('#fraudSummary').textContent().catch(() => '') + + // Log detailed information + if (headerText) { + this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Header: ${headerText.trim()}`, 'error') + } + if (summaryText) { + this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Summary: ${summaryText.trim().substring(0, 200)}...`, 'error') + } + + // Disable account in accounts.jsonc + try { + const { disableBannedAccount } = await import('../../util/state/AccountDisabler') + await disableBannedAccount(email, 'Account suspended by Microsoft Rewards') + this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `✓ Account ${email} disabled in accounts.jsonc`, 'warn') + } catch (disableError) { + this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Failed to disable account in config: ${disableError}`, 'error') + } + + // Send incident alert + const incident: SecurityIncident = { + kind: 'Account Suspended', + account: email, + details: [ + headerText?.trim() || 'Account suspended', + summaryText?.trim().substring(0, 300) || 'Microsoft Rewards violations detected' + ], + next: [ + 'Account has been automatically disabled in accounts.jsonc', + 'Review suspension details at https://rewards.bing.com', + 'Contact Microsoft Support if you believe this is an error' + ], + docsUrl: 'https://support.microsoft.com/topic/c5ab735d-c6d9-4bb9-30ad-d828e954b6a9' + } + await this.securityUtils.sendIncidentAlert(incident, 'critical') + + // Engage global standby + this.bot.compromisedModeActive = true + this.bot.compromisedReason = 'account-suspended' + this.securityUtils.startCompromisedInterval() + await this.bot.engageGlobalStandby('account-suspended', email).catch(logError('LOGIN-SECURITY', 'Global standby engagement failed', this.bot.isMobile)) + + return true + } + } + + return false + } catch (error) { + this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Check failed: ${error}`, 'warn') + return false + } + } } diff --git a/src/functions/login/TotpHandler.ts b/src/functions/login/TotpHandler.ts index 57acd9c..7315e2c 100644 --- a/src/functions/login/TotpHandler.ts +++ b/src/functions/login/TotpHandler.ts @@ -10,6 +10,7 @@ export class TotpHandler { private lastTotpSubmit = 0 private totpAttempts = 0 private currentTotpSecret?: string + private passkeyHandler?: any // Will be set by Login class to avoid circular dependency // Unified selector system - DRY principle private static readonly TOTP_SELECTORS = { @@ -62,6 +63,10 @@ export class TotpHandler { this.bot = bot } + public setPasskeyHandler(handler: any) { + this.passkeyHandler = handler + } + public setTotpSecret(secret?: string) { this.currentTotpSecret = (secret && secret.trim()) || undefined this.lastTotpSubmit = 0 @@ -109,6 +114,18 @@ export class TotpHandler { await this.submitTotpCode(page, selector, secret) this.totpAttempts += 1 this.lastTotpSubmit = Date.now() + + // Handle potential Passkey QR code dialog that appears after TOTP submission + if (this.passkeyHandler) { + await this.bot.utils.wait(800) // Brief wait for dialog to appear + try { + await this.passkeyHandler.handlePasskeyPrompts(page, 'main') + } catch (error) { + // Non-critical: continue even if passkey handling fails + this.bot.log(this.bot.isMobile, 'LOGIN', `Passkey handling after TOTP: ${error}`, 'warn') + } + } + await this.bot.utils.wait(1200) return true } diff --git a/src/util/state/AccountDisabler.ts b/src/util/state/AccountDisabler.ts new file mode 100644 index 0000000..dffeaff --- /dev/null +++ b/src/util/state/AccountDisabler.ts @@ -0,0 +1,120 @@ +import * as fs from 'fs' +import * as path from 'path' + +// Strip JSON comments helper +function stripJsonComments(text: string): string { + return text.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '') +} + +/** + * Disable a banned account in accounts.jsonc by setting enabled=false and adding a comment + * @param email Account email to disable + * @param reason Ban reason (e.g., 'Account suspended by Microsoft') + */ +export async function disableBannedAccount(email: string, reason: string): Promise { + try { + // Find accounts.jsonc file + const candidates = [ + path.join(process.cwd(), 'src', 'accounts.jsonc'), + path.join(process.cwd(), 'accounts.jsonc'), + path.join(__dirname, '../../src', 'accounts.jsonc'), + path.join(__dirname, '../../', 'accounts.jsonc') + ] + + let accountsFilePath: string | null = null + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + accountsFilePath = candidate + break + } + } + + if (!accountsFilePath) { + throw new Error('accounts.jsonc file not found') + } + + // Read current content + const rawContent = fs.readFileSync(accountsFilePath, 'utf-8') + + // Parse accounts (support both array and object with accounts property) + const cleaned = stripJsonComments(rawContent) + const parsed = JSON.parse(cleaned) + const accountsArray = Array.isArray(parsed) ? parsed : parsed.accounts + + if (!Array.isArray(accountsArray)) { + throw new Error('Invalid accounts.jsonc structure') + } + + // Find the account + const accountIndex = accountsArray.findIndex((acc: unknown) => acc.email === email) + if (accountIndex === -1) { + throw new Error(`Account ${email} not found in accounts.jsonc`) + } + + // Check if already disabled + if (accountsArray[accountIndex].enabled === false) { + console.log(`[ACCOUNT-BAN] Account ${email} is already disabled`) + return + } + + // Disable the account + accountsArray[accountIndex].enabled = false + + // Rebuild the file with comments + const timestamp = new Date().toISOString().split('T')[0] + const banComment = `// BANNED ${timestamp}: ${reason}` + + // Convert back to JSON with formatting + let newContent: string + if (Array.isArray(parsed)) { + // Array format + newContent = '[\n' + accountsArray.forEach((acc: unknown, idx: number) => { + if (idx === accountIndex) { + newContent += ` ${banComment}\n` + } + newContent += ' ' + JSON.stringify(acc, null, 2).split('\n').join('\n ') + if (idx < accountsArray.length - 1) { + newContent += ',' + } + newContent += '\n' + }) + newContent += ']\n' + } else { + // Object format with accounts property + const updatedParsed = { ...parsed, accounts: accountsArray } + const jsonStr = JSON.stringify(updatedParsed, null, 2) + + // Insert comment before the banned account + const lines = jsonStr.split('\n') + const emailPattern = `"email": "${email}"` + const emailLineIndex = lines.findIndex(line => line.includes(emailPattern)) + + if (emailLineIndex > 0) { + // Find the start of this account object (opening brace) + let accountStartIndex = emailLineIndex + for (let i = emailLineIndex; i >= 0; i--) { + const line = lines[i] + if (line && line.trim().startsWith('{')) { + accountStartIndex = i + break + } + } + + // Insert comment before the account object + const targetLine = lines[accountStartIndex] + const indent = (targetLine && targetLine.match(/^\s*/)?.[0]) || ' ' + lines.splice(accountStartIndex, 0, `${indent}${banComment}`) + } + + newContent = lines.join('\n') + '\n' + } + + // Write back to file + fs.writeFileSync(accountsFilePath, newContent, 'utf-8') + + console.log(`[ACCOUNT-BAN] ✓ Disabled account ${email} in ${accountsFilePath}`) + } catch (error) { + throw new Error(`Failed to disable banned account ${email}: ${error instanceof Error ? error.message : String(error)}`) + } +}