fix: integrate hardened APT security boundary

This commit is contained in:
2026-08-19 21:36:24 +00:00
parent b6df6f1a66
commit 2f492b662d
14 changed files with 1113 additions and 1730 deletions
+34 -96
View File
@@ -1,112 +1,50 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project
## Project Overview Alta Proxy Tool (APT) is a Windows-only Electron app. Its source of record and releases are on GitPeji:
Alta Proxy Tool (APT) — an Electron desktop app that authenticates with Avigilon Alta Video deployments via a companion Chrome extension, discovers cameras, and launches `aware-cam-proxy.exe` to establish camera connections. Authentication uses cookie import from Chrome — no username/password login flow. Windows-only due to the proxy executable. - Repository: `https://git.pejicorp.com/peji/Alta-Proxy-Tool`
- Releases: `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases`
## Repository The app imports an Alta Video session through a stable-ID paired Chrome extension, discovers cameras, and directly launches the fixed `aware-cam-proxy.exe` helper.
- **GitHub**: https://github.com/PageZ948/Alta-Proxy-Tool (private)
- **Branch**: master
- **Git identity**: Zac <zpage948@gmail.com> (repo-local config)
## Commands ## Commands
```bash ```bash
npm start # Run the app npm start
npm run dev # Run with DevTools open (--dev flag) npm run dev
npm run build # Build portable Windows .exe (output: dist/) npm test
npm run build-test # Build to directory without packaging npm run check
npm run build-test
npm run build
``` ```
No test framework is configured. No linter is configured. ## Required security architecture
## Architecture - Main owns `SessionStore`, `AltaClient`, `ProxyProcessManager`, bridge authentication, and safe update checking.
- Renderer/preload contracts must never carry the Alta deployment credential or cookie.
- IPC methods are narrow and sender-validated against the main local file frame.
- Bridge is exactly `127.0.0.1:18247`, exact committed extension origin, and `X-APT-Pairing` authenticated.
- Persist only the pairing hash envelope under Electron `userData`; plaintext is shown once after first run/rotation.
- Proxy helper launches directly with `shell: false`; stop only app-owned child processes.
- Update behavior is check-only against the exact GitPeji API. The only follow-up action opens the fixed GitPeji releases page externally.
- Never add download/install/replace logic, arbitrary URLs, redirect following, shell scripts, broad process killing, hardcoded bridge secrets, or credential-bearing renderer state.
- Synthetic tests only: never use a real tenant or Alta session.
This is a vanilla Electron app (no React/Vue/framework). Core files: ## File map
``` - `main.js`: Electron adapter, lifecycle, trusted IPC registration, fixed bridge listener
main.js → Electron main process: IPC handlers, API calls (axios), - `src/electron-runtime.js`: runtime orchestration, pairing persistence, bridge request contract
cookie proxy process spawning, local HTTP cookie server - `src/session-store.js`: in-memory credential boundary
preload.js → contextBridge exposing window.electronAPI with IPC wrappers - `src/alta-client.js`: bounded same-origin Alta API client
renderer.js → All UI logic: DOM manipulation, state management, event handlers - `src/proxy-launch.js`: Windows direct-spawn owned-child manager
index.html → Static HTML shell, no inline scripts (CSP enforced) - `src/update-policy.js`: GitPeji check-only policy
styles.css → Dark theme using CSS custom properties - `src/bridge-auth.js`: stable extension origin, scrypt pairing, limits/deadlines
``` - `preload.js`: narrow methods and event payload stripping
- `renderer.js`: non-secret connection/device/proxy/pairing/update UI
- `test/runtime-contract.test.js`: integration and forbidden-source policy
A companion Chrome extension lives in `chrome-extension/`: ## Scope
``` Windows is the current supported runtime because `aware-cam-proxy.exe` is Windows-specific. Do not upgrade dependencies or change production/Tool Hub as part of runtime hardening unless explicitly requested.
chrome-extension/
manifest.json → Manifest V3, cookies + activeTab permissions
popup.html → Extension popup UI
popup.css → Dark theme matching the Electron app
popup.js → Tab detection, cookie retrieval, POST to localhost
icon*.png → Placeholder icons
```
### Authentication Flow
There is no login form or profile system. Authentication works exclusively through the Chrome extension cookie bridge:
1. User logs into Alta deployment in Chrome
2. Clicks the Chrome extension popup → "Send Cookie to APT"
3. Extension POSTs `{deploymentUrl, cookieValue}` to `http://127.0.0.1:18247/cookie` with `X-APT-Token` header
4. `main.js` HTTP server validates and forwards via IPC push to renderer
5. `renderer.js` `handleExtensionCookie()` sets session state, auto-populates cookie key, fetches devices
The extension is loaded unpacked via `chrome://extensions/` → Developer mode → Load unpacked → select `chrome-extension/`.
### IPC Communication Pattern
Most cross-process communication follows the request/response pattern:
1. `main.js` registers handler: `ipcMain.handle('channel-name', async (event, params) => { ... })`
2. `preload.js` exposes it: `channelName: (params) => ipcRenderer.invoke('channel-name', params)`
3. `renderer.js` calls it: `const result = await window.electronAPI.channelName(params)`
All handlers return `{ success: boolean, message?: string, ...data }`.
There is one **push-pattern** channel for the Chrome extension cookie bridge:
- `main.js` sends: `mainWindow.webContents.send('extension-cookie-received', data)`
- `preload.js` bridges: `ipcRenderer.on('extension-cookie-received', callback)`
- `renderer.js` listens via `window.electronAPI.onExtensionCookie(callback)`
### IPC Channels
| Channel | Purpose |
|---------|---------|
| `api-get-devices` | GET /api/v1/devices with cookie auth |
| `api-get-auth-info` | GET /api/v1/auth to verify session |
| `camera-proxy-cookie-launch` | Spawns aware-cam-proxy.exe (cookie method) |
| `camera-proxy-stop` | Kills all proxy processes via taskkill/powershell |
| `extension-cookie-received` | Push channel: cookie data from Chrome extension → renderer |
### State Management (renderer.js)
All connection state lives in the `sessionData` object (deploymentUrl, cookies, isConnected). There is no separate `isConnected` flag — always use `sessionData.isConnected`.
Active cookie proxy processes are tracked in `activeCookieProxyConnections` Map, keyed by device GUID.
### Security Model
- Context isolation enabled, nodeIntegration disabled
- CSP meta tag: `script-src 'self'` — no inline scripts or onclick handlers allowed
- Batch file inputs are sanitized via `sanitizeBatchInput()` to prevent command injection
- Local HTTP cookie server (port 18247) bound to `127.0.0.1` only
- Cookie server validates: shared token header, CORS restricted to `chrome-extension://` origins, deployment URL must be `*.avasecurity.com` or `*.avigilon.com` over HTTPS, type/length limits on all inputs, 64KB body size limit
## Key Conventions
- No inline event handlers in HTML — all use `addEventListener` in renderer.js
- All user-provided content rendered to DOM must go through `escapeHtml()` (XSS prevention)
- External processes spawned with `detached: true` + `unref()` so they survive if the app closes
- Device list filters out cloud cameras (`capabilities.localStorage === false` only)
- `clearDeviceList()` must NOT clear proxy connection Maps (proxies may still be running)
## External Executable
- `aware-cam-proxy.exe` — cookie-based auth proxy (required)
Not bundled via npm. Must be in the app root directory. Gitignored along with `*.pdf`, `node_modules/`, and `dist/`.
+47 -168
View File
@@ -1,186 +1,65 @@
# Alta Video Camera Proxy # Alta Video Camera Proxy
An Electron desktop application for managing Alta Video camera proxy connections. Authenticates via a companion Chrome extension that imports your existing Alta session cookie, discovers cameras, and launches proxy connections. APT is a Windows Electron desktop app that imports an existing Alta Video session through its paired Chrome extension, discovers local cameras, and launches `aware-cam-proxy.exe` without exposing Alta credentials to the renderer.
## Features ## Security boundary
- **Chrome Extension Authentication**: Import your Alta session cookie from Chrome with one click — no manual login - `main.js` owns the in-memory `SessionStore`, `AltaClient`, `ProxyProcessManager`, bridge pairing envelope, and update checker.
- **API Integration**: Device discovery via Alta Video API using cookie auth - The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value.
- **Camera Proxy Management**: Launch and manage camera proxy connections - The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin.
- **Device Filtering**: Automatically filters to show only local (non-cloud) cameras - The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it.
- **Device Search**: Quick search functionality to find cameras by name, ID, IP, or model - Proxy launch is a direct `spawn` of the fixed helper with `shell: false`; stop actions can target only children owned by this app.
- **Real-time Status**: Live connection status and device online/offline indicators - Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update.
- **Auto-Update**: Checks for updates on startup via GitHub Releases, with one-click in-place update
- **Modern Dark UI**: Professional dark-mode interface with responsive design
## Prerequisites ## Requirements
- **Node.js** (version 14 or higher) - Windows (current supported runtime scope)
- **npm** (comes with Node.js) - Node.js and npm for development
- **Google Chrome** (for the authentication extension) - Chrome with the bundled extension loaded unpacked
- **Windows OS** (required for camera proxy executable) - `aware-cam-proxy.exe` beside the development app or packaged portable executable
- **aware-cam-proxy.exe** (camera proxy executable) — must be placed in the application directory - An active Alta Video login in Chrome
- **An active Alta Video session** in Chrome (logged in to your deployment)
## Installation ## Setup and pairing
1. Clone or download this project
2. Install dependencies:
```bash
npm install
```
3. Place `aware-cam-proxy.exe` in the project root directory
### Chrome Extension Setup
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable **Developer mode** (toggle in top-right)
3. Click **Load unpacked**
4. Select the `chrome-extension/` folder from this project
5. The extension icon will appear in the Chrome toolbar
## Usage
### Starting the Application
```bash ```bash
npm install
npm start npm start
``` ```
Or for development mode with DevTools: 1. Open `chrome://extensions`, enable Developer mode, and load `chrome-extension/` unpacked.
```bash 2. Start APT. Under **Bridge Pairing**, copy the one-time secret.
npm run dev 3. Open the extension pairing settings, paste the secret, and save it.
``` 4. Visit your Alta deployment in Chrome and use **Send to APT**.
5. Select a local camera and choose **Start Proxy**.
### Connecting to Alta Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication.
1. **Log into your Alta deployment** in Chrome (e.g., `https://your-site.eu1.aware.avasecurity.com`) ## Development and verification
2. **Click the extension icon** in Chrome — it will detect the Alta tab
3. **Click "Send Cookie to APT"** — the app will connect and load devices automatically ```bash
npm test # Node test suite, including synthetic runtime contracts
### Launching Camera Proxy npm run check # Syntax checks plus tests
npm run build-test
1. **Connect via Chrome extension** (above) ```
2. **Select a device** from the left sidebar
3. **Click "Start Camera Proxy"** Tests use synthetic sessions/transports/processes only. Never add a real Alta tenant URL or session value to fixtures, logs, screenshots, or commits.
4. A command prompt window will open with the proxy connection
Core files:
### Updating
- `main.js` — Electron lifecycle, trusted-sender IPC, fixed loopback server
The app checks for updates automatically 2 seconds after launch. If a newer version is available on GitHub Releases, the "Check for Updates" button in the header will show a green badge. - `src/electron-runtime.js` — pairing persistence, bridge handler, narrow runtime orchestration
- `src/session-store.js`, `src/alta-client.js` — main-only Alta session and requests
- **Manual check**: Click "Check for Updates" in the top-right corner - `src/proxy-launch.js` — fixed shell-free helper process management
- **Install**: The update modal shows release notes — click "Install Update" to download and replace the current executable - `src/update-policy.js` — exact GitPeji check-only release policy
- The app will quit, swap the `.exe`, and relaunch automatically - `preload.js` — narrow context bridge
- `renderer.js`, `index.html`, `styles.css` — non-secret UI
## API Endpoints Used - `chrome-extension/` — stable-ID paired cookie sender
- `test/` — pure and end-to-end contract tests
- **Device List**: `GET /api/v1/devices` — Retrieve all devices
- **Auth Info**: `GET /api/v1/auth` — Verify authentication status ## Building
## How It Works
```
Chrome Extension (popup click)
→ POST http://127.0.0.1:18247/cookie
→ Electron app HTTP server receives cookie
→ Sets session state, fetches devices
→ User selects device → launches aware-cam-proxy.exe
```
The Electron app runs a local HTTP server on port 18247 that only accepts requests from Chrome extensions with a shared token. The Chrome extension reads the `va` session cookie from the active Alta tab and sends it to the app.
## Security
- **Context Isolation**: Renderer process is isolated from Node.js APIs
- **Preload Script**: Secure IPC communication between main and renderer processes
- **CSP Enforced**: `script-src 'self'` — no inline scripts allowed
- **Localhost Only**: Cookie server binds to `127.0.0.1`, not accessible from network
- **CORS Restricted**: Only `chrome-extension://` origins accepted
- **Domain Validation**: Only `*.avasecurity.com` and `*.avigilon.com` URLs accepted
- **Input Sanitization**: Batch file inputs sanitized to prevent command injection
- **Size Limits**: 64KB body limit on cookie server, type/length validation on all inputs
## File Structure
```
├── main.js # Main process (IPC, API calls, proxy spawning, cookie server)
├── renderer.js # Renderer process (UI logic, state management)
├── preload.js # Secure IPC bridge (contextBridge)
├── index.html # Static HTML shell (CSP enforced)
├── styles.css # Dark theme styling
├── package.json # Dependencies and build config
├── chrome-extension/ # Chrome extension for cookie import
│ ├── manifest.json # Manifest V3
│ ├── popup.html # Extension popup UI
│ ├── popup.css # Dark theme styling
│ ├── popup.js # Tab detection, cookie retrieval
│ └── icon*.png # Extension icons
├── assets/
│ └── icon.png # Application icon
├── CLAUDE.md # Claude Code project instructions
└── README.md # This file
```
**External executable** (not included in repo):
- `aware-cam-proxy.exe` — cookie-based auth proxy (required, place in app root)
## Troubleshooting
### Connection Issues
- Ensure you are **logged into Alta in Chrome** before clicking the extension
- Verify the extension shows "Detected: [hostname]" in green
- If extension shows "Alta Proxy Tool is not running" — start the Electron app first
- If "Session cookie has expired" — log into Alta again in Chrome
- Check that the app console shows "Cookie server listening on http://127.0.0.1:18247"
### Camera Proxy Issues
- **Executable not found**: Ensure `aware-cam-proxy.exe` is in the application directory
- **Proxy won't start**: Check that you're connected and have selected a device
- **Command window closes immediately**: Check network connectivity to the deployment
### Device List Issues
- Ensure you're connected via the Chrome extension first
- Check that your user account has permissions to view devices
- **No devices shown**: You may only have cloud cameras which are filtered out
- Use the search box to find specific devices
## Building for Distribution
```bash ```bash
# Build portable Windows executable
npm run build npm run build
# Output: dist/AltaCameraProxy-1.0.0-portable.exe
``` ```
**Important**: Copy `aware-cam-proxy.exe` to the same directory as the built executable before distribution. The output remains Windows-only because the external camera helper is Windows-specific. Copy `aware-cam-proxy.exe` beside the portable APT executable before use. Dependencies are intentionally unchanged in this hardening release.
## Limitations
- **Windows Only**: Camera proxy executable is Windows-specific
- **Chrome Required**: Authentication requires the Chrome extension
- **Local Cameras Only**: Automatically filters out cloud-based cameras
- **No Session Refresh**: Sessions may expire and require re-import from Chrome
- **Executable Required**: `aware-cam-proxy.exe` must be obtained separately
- **Update Requires Write Access**: Self-update needs write permission to the directory containing the `.exe`
## Development
To modify or extend this application:
1. **Main Process** ([main.js](main.js)): App lifecycle, API requests, proxy spawning, cookie server
2. **Renderer Process** ([renderer.js](renderer.js)): UI interactions and state management
3. **Preload Script** ([preload.js](preload.js)): Secure IPC bridge with context isolation
4. **Chrome Extension** ([chrome-extension/](chrome-extension/)): Cookie import from browser
5. **Styling** ([styles.css](styles.css)): Dark mode theme and responsive design
### Adding New IPC Endpoints
1. Add handler in [main.js](main.js) using `ipcMain.handle()`
2. Expose method in [preload.js](preload.js) via `contextBridge.exposeInMainWorld()`
3. Call from [renderer.js](renderer.js) using `window.electronAPI.yourMethod()`
## License
MIT License - Feel free to modify and distribute as needed.
+1 -1
View File
@@ -83,7 +83,7 @@ function createPopupController({
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-APT-Pairing-Secret': pairingSecret 'X-APT-Pairing': pairingSecret
}, },
body: JSON.stringify({ deploymentUrl: detectedOrigin, cookieValue }) body: JSON.stringify({ deploymentUrl: detectedOrigin, cookieValue })
}); });
+6 -5
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<!-- GitHub Pages rebuild marker: 2026-05-22 -->
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alta Proxy Tool</title> <title>Alta Proxy Tool</title>
@@ -303,7 +303,7 @@
<img src="icon.png" alt="Alta Proxy Tool"> <img src="icon.png" alt="Alta Proxy Tool">
<span>Alta Proxy Tool</span> <span>Alta Proxy Tool</span>
</div> </div>
<a href="https://github.com/PageZ948/Alta-Proxy-Tool" class="header-link">GitHub</a> <a href="https://git.pejicorp.com/peji/Alta-Proxy-Tool" class="header-link">GitPeji</a>
</div> </div>
</header> </header>
@@ -313,7 +313,7 @@
<div class="hero"> <div class="hero">
<img src="icon.png" alt="" class="hero-icon"> <img src="icon.png" alt="" class="hero-icon">
<h1>Alta Proxy Tool</h1> <h1>Alta Proxy Tool</h1>
<a href="https://github.com/PageZ948/Alta-Proxy-Tool/releases/latest/download/AltaProxyToolKit.zip" class="btn-download">Download Kit for Windows</a> <a href="https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases" class="btn-download">View Windows Releases</a>
</div> </div>
<section class="setup"> <section class="setup">
@@ -321,8 +321,9 @@
<ol class="setup-steps"> <ol class="setup-steps">
<li>Extract the zip to any folder</li> <li>Extract the zip to any folder</li>
<li>Load <code>chrome-extension/</code> in Chrome via <code>chrome://extensions</code> (Developer mode)</li> <li>Load <code>chrome-extension/</code> in Chrome via <code>chrome://extensions</code> (Developer mode)</li>
<li>Start APT and paste its one-time pairing secret into the extension settings</li>
<li>Log into your Alta deployment in Chrome</li> <li>Log into your Alta deployment in Chrome</li>
<li>Click the extension icon and send cookies to the app</li> <li>Click the extension icon and securely send the session to the app</li>
<li>Run <strong>AltaCameraProxy.exe</strong> and start proxying</li> <li>Run <strong>AltaCameraProxy.exe</strong> and start proxying</li>
</ol> </ol>
<p class="setup-note">Requires Windows 10+, Google Chrome, and an Avigilon Alta account.</p> <p class="setup-note">Requires Windows 10+, Google Chrome, and an Avigilon Alta account.</p>
@@ -333,7 +334,7 @@
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<a href="https://github.com/PageZ948/Alta-Proxy-Tool">Alta Proxy Tool on GitHub</a> <a href="https://git.pejicorp.com/peji/Alta-Proxy-Tool">Alta Proxy Tool on GitPeji</a>
</div> </div>
</footer> </footer>
+33 -41
View File
@@ -3,45 +3,35 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none';">
<title>Alta Video Camera Proxy with API</title> <title>Alta Video Camera Proxy</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<!-- Main Content Layout -->
<div class="main-layout"> <div class="main-layout">
<!-- Left Sidebar - Available Devices -->
<aside class="devices-sidebar"> <aside class="devices-sidebar">
<div class="sidebar-header"> <div class="sidebar-header"><h2>Available Devices</h2></div>
<h2>Available Devices</h2>
</div>
<div id="deviceStatus" class="status-message"></div> <div id="deviceStatus" class="status-message"></div>
<!-- Device Search -->
<div class="device-search-container"> <div class="device-search-container">
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input"> <input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input" autocomplete="off">
</div> </div>
<div class="device-list-container"> <div class="device-list-container">
<div id="deviceList" class="device-list"> <div id="deviceList" class="device-list">
<p class="placeholder-text">Connect to API to load devices</p> <p class="placeholder-text">Connect through the paired Chrome extension to load devices</p>
</div> </div>
</div> </div>
</aside> </aside>
<!-- Main Content Area -->
<main class="main-content"> <main class="main-content">
<div class="content-header"> <div class="content-header">
<h1>Alta Video Camera Proxy</h1> <h1>Alta Video Camera Proxy</h1>
<button type="button" id="checkUpdateBtn" class="btn-update" title="Check for Updates"> <button type="button" id="checkUpdateBtn" class="btn-update" title="Securely check GitPeji for a newer release">
<span class="update-icon">&#x21bb;</span> <span class="update-icon">&#x21bb;</span>
<span class="update-text">Check for Updates</span> <span class="update-text">Check for Updates</span>
</button> </button>
</div> </div>
<!-- API Connection Section -->
<section class="content-section"> <section class="content-section">
<h2>API Connection</h2> <h2>API Connection</h2>
<div class="connection-status"> <div class="connection-status">
@@ -59,21 +49,34 @@
<div id="connectionStatus" class="status-message"></div> <div id="connectionStatus" class="status-message"></div>
</section> </section>
<!-- Cookie-Based Camera Proxy Section --> <section class="content-section pairing-section">
<h2>Bridge Pairing</h2>
<p class="section-help">Only the committed APT Chrome extension can send a session. Pair it with a one-time secret.</p>
<div class="status-row">
<label>Pairing:</label>
<span id="pairingState" class="unpaired">Checking...</span>
</div>
<div id="pairingSecretRow" class="pairing-secret-row" style="display: none;">
<label for="pairingSecret">One-time secret:</label>
<input type="text" id="pairingSecret" readonly autocomplete="off" spellcheck="false">
<small>Paste this into the extension pairing settings now. APT will not show it again.</small>
</div>
<div class="proxy-buttons">
<button type="button" id="rotatePairingBtn" class="btn-primary">Generate / Rotate</button>
<button type="button" id="revokePairingBtn" class="btn-outline">Revoke</button>
</div>
</section>
<section class="content-section"> <section class="content-section">
<h2>Camera Proxy</h2> <h2>Camera Proxy</h2>
<div class="proxy-controls"> <div class="proxy-controls">
<div class="input-row"> <div class="input-row">
<label for="cookieDeviceUUID">Device UUID:</label> <label for="selectedDeviceId">Device UUID:</label>
<input type="text" id="cookieDeviceUUID" placeholder="(Auto-filled when you select a device from the list)" readonly> <input type="text" id="selectedDeviceId" placeholder="Select a device from the list" readonly>
</div>
<div class="input-row" style="display: none;">
<label for="cookieKey">Cookie Key:</label>
<input type="text" id="cookieKey" placeholder="Paste your cookie key here">
</div> </div>
<div class="proxy-buttons"> <div class="proxy-buttons">
<button type="button" id="startCookieProxyBtn" class="btn-primary" disabled>Start Proxy</button> <button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button>
<button type="button" id="stopCookieProxyBtn" class="btn-outline" disabled>Stop Proxy</button> <button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
</div> </div>
</div> </div>
</section> </section>
@@ -81,26 +84,15 @@
</div> </div>
</div> </div>
<!-- Update Modal --> <div id="updateNotice" class="update-modal-overlay" style="display: none;">
<div id="updateModalOverlay" class="update-modal-overlay" style="display: none;">
<div class="update-modal-card"> <div class="update-modal-card">
<div class="update-modal-header"> <div class="update-modal-header"><h3>Update Available</h3></div>
<h3>Update Available</h3>
<button type="button" id="updateModalCloseBtn" class="update-modal-close">&times;</button>
</div>
<div class="update-modal-body"> <div class="update-modal-body">
<p id="updateModalMessage" class="update-modal-message"></p> <p id="updateMessage" class="update-modal-message"></p>
<div id="updateModalNotes" class="update-modal-notes"></div>
<div id="updateProgressContainer" class="update-progress-container" style="display: none;">
<div class="update-progress-track">
<div id="updateProgressFill" class="update-progress-fill" style="width: 0%"></div>
</div>
<span id="updateProgressText" class="update-progress-text">0%</span>
</div>
</div> </div>
<div class="update-modal-footer"> <div class="update-modal-footer">
<button type="button" id="updateInstallBtn" class="btn-primary">Install Update</button> <button type="button" id="openReleasesBtn" class="btn-primary">Open GitPeji Releases</button>
<button type="button" id="updateLaterBtn" class="btn-outline">Later</button> <button type="button" id="dismissUpdateBtn" class="btn-outline">Later</button>
</div> </div>
</div> </div>
</div> </div>
+137 -598
View File
@@ -1,631 +1,170 @@
'use strict';
const { app, BrowserWindow, ipcMain, shell } = require('electron'); const { app, BrowserWindow, ipcMain, shell } = require('electron');
const path = require('path'); const http = require('node:http');
const fs = require('fs'); const path = require('node:path');
const os = require('os'); const { pathToFileURL } = require('node:url');
const axios = require('axios'); const { createSessionStore } = require('./src/session-store');
const https = require('https'); const { createAltaClient } = require('./src/alta-client');
const { spawn } = require('child_process'); const { createProxyManager } = require('./src/proxy-launch');
const http = require('http'); const { checkForUpdate } = require('./src/update-policy');
const {
AppRuntime,
PAIRING_ENVELOPE_FILENAME,
PairingController,
createBridgeHandler,
} = require('./src/electron-runtime');
let mainWindow; const BRIDGE_HOST = '127.0.0.1';
let activeProxyProcesses = new Map(); // Track active camera proxy processes const BRIDGE_PORT = 18247;
let cookieServer = null;
const COOKIE_SERVER_PORT = 18247; let mainWindow = null;
const COOKIE_SERVER_TOKEN = 'apt-local-bridge-token'; let bridgeServer = null;
let runtime = null;
let pairingController = null;
let firstRunPairingSecret = null;
// Get the directory where the user-facing executable resides.
// In portable builds, __dirname points to a temp extraction directory,
// so we use the actual .exe location instead.
function getAppDirectory() { function getAppDirectory() {
if (app.isPackaged) { return app.isPackaged
return path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe')); ? path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'))
: __dirname;
}
function sendConnectionState() {
if (mainWindow && !mainWindow.isDestroyed() && runtime) {
mainWindow.webContents.send('connection-state-changed', runtime.getConnectionState());
} }
return __dirname;
}
// Sanitize strings before embedding in batch files to prevent command injection
function sanitizeBatchInput(input) {
if (typeof input !== 'string') return '';
// Remove characters that have special meaning in batch/cmd: & | < > ^ % " ` !
return input.replace(/[&|<>^%"`!]/g, '');
}
function startCookieServer() {
cookieServer = http.createServer((req, res) => {
// CORS headers — only allow Chrome extension origins
const origin = req.headers.origin || '';
if (origin.startsWith('chrome-extension://')) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Token');
// Handle preflight
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// Only accept POST /cookie
if (req.method !== 'POST' || req.url !== '/cookie') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Not found' }));
return;
}
// Verify shared token
if (req.headers['x-apt-token'] !== COOKIE_SERVER_TOKEN) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Forbidden' }));
return;
}
// Read body with 64KB size limit
let body = '';
let bodySize = 0;
const MAX_BODY_SIZE = 65536;
req.on('data', (chunk) => {
bodySize += chunk.length;
if (bodySize > MAX_BODY_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Payload too large' }));
req.destroy();
return;
}
body += chunk;
});
req.on('end', () => {
try {
const data = JSON.parse(body);
const { deploymentUrl, cookieValue } = data;
if (!deploymentUrl || !cookieValue) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Missing deploymentUrl or cookieValue' }));
return;
}
// Validate types and lengths
if (typeof deploymentUrl !== 'string' || typeof cookieValue !== 'string' ||
deploymentUrl.length > 512 || cookieValue.length > 4096) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid parameter types or lengths' }));
return;
}
// Validate deployment URL is an Alta domain
try {
const parsed = new URL(deploymentUrl);
const isAltaDomain = parsed.hostname.endsWith('.avasecurity.com') ||
parsed.hostname.endsWith('.avigilon.com');
if (!isAltaDomain || parsed.protocol !== 'https:') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL domain' }));
return;
}
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL' }));
return;
}
if (mainWindow && !mainWindow.isDestroyed()) {
const cookies = ['va=' + cookieValue];
mainWindow.webContents.send('extension-cookie-received', {
deploymentUrl: deploymentUrl.replace(/\/$/, ''),
cookies,
cookieValue
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Cookie received' }));
} else {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Application window not available' }));
}
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid JSON' }));
}
});
});
cookieServer.listen(COOKIE_SERVER_PORT, '127.0.0.1', () => {
console.log(`Cookie server listening on http://127.0.0.1:${COOKIE_SERVER_PORT}`);
});
cookieServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Cookie server error: Port ${COOKIE_SERVER_PORT} is already in use`);
} else {
console.error('Cookie server error:', err.message);
}
});
} }
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
height: 900, height: 900,
icon: path.join(__dirname, 'assets', 'icon.png'),
title: 'Alta Video Camera Proxy',
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
preload: path.join(__dirname, 'preload.js') sandbox: true,
preload: path.join(__dirname, 'preload.js'),
}, },
icon: path.join(__dirname, 'assets', 'icon.png'), // Optional icon
title: 'Alta Video Camera Proxy with API'
}); });
mainWindow.loadFile('index.html'); mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
mainWindow.webContents.on('will-navigate', (event) => event.preventDefault());
mainWindow.loadFile(path.join(__dirname, 'index.html'));
if (process.argv.includes('--dev')) mainWindow.webContents.openDevTools();
}
// Open DevTools in development function isTrustedSender(event) {
if (process.argv.includes('--dev')) { if (!mainWindow || mainWindow.isDestroyed() || !event) return false;
mainWindow.webContents.openDevTools(); const webContents = mainWindow.webContents;
} const frame = event.senderFrame;
const expectedUrl = pathToFileURL(path.join(__dirname, 'index.html')).href;
return event.sender === webContents &&
frame === webContents.mainFrame &&
frame.url === expectedUrl;
}
function registerIpc(channel, handler) {
ipcMain.handle(channel, async (event, ...args) => {
if (!isTrustedSender(event)) throw new Error('Forbidden IPC sender');
return handler(...args);
});
}
function registerIpcHandlers() {
registerIpc('get-devices', () => runtime.getDevices());
registerIpc('get-device-sites', () => runtime.getDeviceSites());
registerIpc('get-auth-info', () => runtime.getAuthInfo());
registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId));
registerIpc('stop-proxy', async (key) => {
const result = await runtime.stopProxy(key);
sendConnectionState();
return result;
});
registerIpc('disconnect', () => {
const state = runtime.disconnect();
sendConnectionState();
return state;
});
registerIpc('get-connection-state', () => runtime.getConnectionState());
registerIpc('check-for-updates', () => runtime.checkForUpdates());
registerIpc('open-fixed-releases-page', () => runtime.openFixedReleasesPage());
registerIpc('rotate-pairing', () => {
firstRunPairingSecret = null;
return pairingController.rotate();
});
registerIpc('revoke-pairing', () => {
firstRunPairingSecret = null;
return pairingController.revoke();
});
registerIpc('get-pairing-status', () => {
const result = pairingController.getStatus();
if (firstRunPairingSecret) {
result.secret = firstRunPairingSecret;
firstRunPairingSecret = null;
}
return result;
});
}
function startBridgeServer() {
const handler = createBridgeHandler({
bridgeAuth: pairingController.bridgeAuth,
sessionStore: runtime.sessionStore,
onConnectionStateChanged: sendConnectionState,
});
bridgeServer = http.createServer((request, response) => {
handler(request, response).catch(() => {
if (!response.writableEnded) {
response.writeHead(500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
response.end(JSON.stringify({ success: false, message: 'Bridge request failed' }));
}
});
});
bridgeServer.on('clientError', (_error, socket) => socket.destroy());
bridgeServer.on('error', (error) => {
console.error(`Bridge server unavailable (${error.code || 'UNKNOWN'}).`);
});
bridgeServer.listen(BRIDGE_PORT, BRIDGE_HOST);
} }
app.whenReady().then(() => { app.whenReady().then(() => {
const sessionStore = createSessionStore();
const altaClient = createAltaClient({ sessionStore });
const proxyManager = createProxyManager({ appDirectory: getAppDirectory() });
pairingController = new PairingController({
envelopePath: path.join(app.getPath('userData'), PAIRING_ENVELOPE_FILENAME),
});
const pairingState = pairingController.initialize();
firstRunPairingSecret = pairingState.secret || null;
runtime = new AppRuntime({
sessionStore,
altaClient,
proxyManager,
checkForUpdate,
currentVersion: app.getVersion(),
openExternal: (url) => shell.openExternal(url),
});
registerIpcHandlers();
createWindow(); createWindow();
startCookieServer(); startBridgeServer();
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
if (cookieServer) { if (bridgeServer) bridgeServer.close();
cookieServer.close(); if (runtime) {
for (const proxy of runtime.getConnectionState().activeProxies) runtime.stopProxy(proxy.processId);
runtime.sessionStore.dispose();
} }
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') app.quit();
app.quit();
}
}); });
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0 && runtime) createWindow();
createWindow();
}
}); });
// IPC handlers for API communication
ipcMain.handle('api-get-devices', async (event, { deploymentUrl, cookies }) => {
try {
const devicesUrl = `${deploymentUrl}/api/v1/devices`;
// Create axios instance with cookies
const axiosInstance = axios.create({
timeout: 10000,
headers: {
'Cookie': cookies ? cookies.join('; ') : ''
}
});
const response = await axiosInstance.get(devicesUrl);
return {
success: true,
devices: response.data,
message: `Found ${response.data.length} devices`
};
} catch (error) {
console.error('Get devices error:', error);
return {
success: false,
message: error.response?.data?.message || error.message || 'Failed to get devices'
};
}
});
ipcMain.handle('api-get-device-sites', async (event, { deploymentUrl, cookies }) => {
try {
const sitesUrl = `${deploymentUrl}/api/v1/deviceSites`;
const axiosInstance = axios.create({
timeout: 10000,
headers: {
'Cookie': cookies ? cookies.join('; ') : ''
}
});
const response = await axiosInstance.get(sitesUrl);
return {
success: true,
sites: response.data
};
} catch (error) {
console.error('Get device sites error:', error);
return {
success: false,
sites: [],
message: error.response?.data?.message || error.message || 'Failed to get device sites'
};
}
});
ipcMain.handle('api-get-auth-info', async (event, { deploymentUrl, cookies }) => {
try {
const authUrl = `${deploymentUrl}/api/v1/auth`;
const axiosInstance = axios.create({
timeout: 10000,
headers: {
'Cookie': cookies ? cookies.join('; ') : ''
}
});
const response = await axiosInstance.get(authUrl);
return {
success: true,
authInfo: response.data
};
} catch (error) {
console.error('Get auth info error:', error);
return {
success: false,
message: error.response?.data?.message || error.message || 'Failed to get auth info'
};
}
});
// Cookie-based camera proxy functionality
ipcMain.handle('camera-proxy-cookie-launch', async (event, { deploymentUrl, cookieKey, deviceUuid }) => {
try {
// Path to the cookie-based camera proxy executable
const proxyExePath = path.join(getAppDirectory(), 'aware-cam-proxy.exe');
// Check if the executable exists
if (!fs.existsSync(proxyExePath)) {
return {
success: false,
message: 'Cookie-based camera proxy executable not found. Please ensure aware-cam-proxy.exe is in the application directory.'
};
}
// Extract the domain from the deployment URL
let domain = deploymentUrl;
if (domain.startsWith('https://')) {
domain = domain.substring(8);
} else if (domain.startsWith('http://')) {
domain = domain.substring(7);
}
// Remove trailing path segments
domain = domain.split('/')[0];
// Sanitize all inputs before embedding in batch file
const safeDomain = sanitizeBatchInput(domain);
const safeDeviceUuid = sanitizeBatchInput(deviceUuid);
const safeCookieKey = sanitizeBatchInput(cookieKey);
if (!safeDomain || !safeDeviceUuid || !safeCookieKey) {
return {
success: false,
message: 'Invalid characters detected in connection parameters.'
};
}
// Create a batch file to launch the cookie-based camera proxy
const truncatedKey = safeCookieKey.length > 20 ? safeCookieKey.substring(0, 20) + '...' : safeCookieKey;
const batchContent = `@echo off
title APT-Proxy-${safeDeviceUuid}
echo Launching Alta Video Camera Proxy (Cookie Method)...
echo Domain: ${safeDomain}
echo Device UUID: ${safeDeviceUuid}
echo Cookie Key: ${truncatedKey}
echo.
"${proxyExePath}" -a "${safeDomain}" -d "${safeDeviceUuid}" -k "${safeCookieKey}"
echo.
echo Cookie-based camera proxy has finished. Press any key to close this window.
pause >nul`;
const tempDir = os.tmpdir();
const batchPath = path.join(tempDir, `cookie-proxy-${Date.now()}.bat`);
// Write the batch file
fs.writeFileSync(batchPath, batchContent);
console.log('Launching cookie-based camera proxy via batch file:', batchPath);
console.log('Command will be: aware-cam-proxy.exe -a', safeDomain, '-d', safeDeviceUuid, '-k [REDACTED]');
// Launch the batch file in a new command prompt window with unique title
const windowTitle = `APT-Proxy-${safeDeviceUuid}`;
const cmdProcess = spawn('cmd', ['/c', 'start', `"${windowTitle}"`, 'cmd', '/c', batchPath], {
detached: true,
stdio: 'ignore'
});
// Store the process information for later termination
const processInfo = {
process: cmdProcess,
batchPath: batchPath,
deviceUuid: safeDeviceUuid,
startTime: Date.now(),
cookieKey: truncatedKey,
domain: safeDomain,
windowTitle: windowTitle, // Store window title for targeted cleanup
type: 'cookie' // Mark as cookie-based proxy
};
activeProxyProcesses.set(cmdProcess.pid, processInfo);
// Clean up the batch file after a delay
setTimeout(() => {
try {
if (fs.existsSync(batchPath)) {
fs.unlinkSync(batchPath);
}
} catch (error) {
console.log('Could not clean up cookie proxy batch file:', error.message);
}
}, 60000); // Clean up after 1 minute
cmdProcess.unref(); // Allow the parent process to exit independently
// Clean up process tracking when it exits
cmdProcess.on('exit', () => {
activeProxyProcesses.delete(cmdProcess.pid);
});
return {
success: true,
message: `Cookie-based camera proxy launched for ${deviceUuid}!`,
processId: cmdProcess.pid,
deviceUuid: deviceUuid,
type: 'cookie'
};
} catch (error) {
console.error('Failed to launch cookie-based camera proxy:', error);
return {
success: false,
message: `Failed to launch cookie-based camera proxy: ${error.message}`
};
}
});
// Stop camera proxy functionality
ipcMain.handle('camera-proxy-stop', async (event, { processId }) => {
try {
console.log('Attempting to stop camera proxy processes...');
return new Promise((resolve) => {
// Kill all aware-cam-proxy.exe processes by name
const killProxy = spawn('taskkill', ['/f', '/im', 'aware-cam-proxy.exe'], {
stdio: ['ignore', 'pipe', 'pipe']
});
let proxyOutput = '';
let proxyError = '';
killProxy.stdout.on('data', (data) => {
proxyOutput += data.toString();
});
killProxy.stderr.on('data', (data) => {
proxyError += data.toString();
});
killProxy.on('close', (code) => {
// Clean up our process tracking
activeProxyProcesses.clear();
if (code === 0 || proxyOutput.includes('SUCCESS')) {
console.log('Camera proxy processes terminated successfully');
resolve({
success: true,
message: 'Camera proxy processes stopped successfully'
});
} else if (proxyError.includes('not found') || proxyError.includes('No tasks')) {
console.log('No camera proxy processes were running');
resolve({
success: true,
message: 'No camera proxy processes were running'
});
} else {
resolve({
success: true,
message: 'Attempted to stop all camera proxy processes'
});
}
});
killProxy.on('error', (error) => {
console.error('Error with taskkill by name:', error);
activeProxyProcesses.clear();
resolve({
success: false,
message: `Failed to stop camera proxy: ${error.message}`
});
});
});
} catch (error) {
console.error('Failed to stop camera proxy:', error);
return {
success: false,
message: `Failed to stop camera proxy: ${error.message}`
};
}
});
// --- Self-Update Functionality ---
// Compare semver versions: returns -1 if a < b, 0 if equal, 1 if a > b
function compareVersions(a, b) {
// Strip pre-release tags (e.g. "1.2.3-beta.1" → "1.2.3")
const cleanA = a.replace(/-.*$/, '');
const cleanB = b.replace(/-.*$/, '');
const partsA = cleanA.split('.').map(Number);
const partsB = cleanB.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return -1;
if (numA > numB) return 1;
}
return 0;
}
// Follow HTTPS redirects and return the final response (for GitHub asset downloads)
function httpsGetFollowRedirects(url, callback, redirectCount = 0) {
if (redirectCount >= 5) {
return callback(null, new Error('Too many redirects'));
}
const parsed = new URL(url);
if (parsed.protocol !== 'https:') {
return callback(null, new Error('Only HTTPS URLs are allowed'));
}
https.get(url, { headers: { 'User-Agent': 'Alta-Proxy-Tool' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = new URL(res.headers.location, url).href;
httpsGetFollowRedirects(redirectUrl, callback, redirectCount + 1);
} else {
callback(res);
}
}).on('error', (err) => {
callback(null, err);
});
}
ipcMain.handle('get-current-version', async () => {
return { success: true, version: app.getVersion() };
});
ipcMain.handle('check-for-updates', async () => {
try {
const response = await axios.get(
'https://api.github.com/repos/PageZ948/Alta-Proxy-Tool/releases/latest',
{
timeout: 10000,
headers: { 'User-Agent': 'Alta-Proxy-Tool', 'Accept': 'application/vnd.github.v3+json' }
}
);
const release = response.data;
const latestVersion = release.tag_name.replace(/^v/, '');
const currentVersion = app.getVersion();
const updateAvailable = compareVersions(currentVersion, latestVersion) < 0;
// Find the portable .exe asset
const exeAsset = release.assets.find(a => /AltaCameraProxy-.*-portable\.exe$/i.test(a.name));
const downloadUrl = exeAsset ? exeAsset.browser_download_url : null;
return {
success: true,
updateAvailable,
currentVersion,
latestVersion,
downloadUrl,
releaseNotes: release.body || '',
releaseName: release.name || `v${latestVersion}`
};
} catch (error) {
if (error.response && error.response.status === 404) {
return { success: true, updateAvailable: false, currentVersion: app.getVersion(), message: 'No releases available yet' };
}
if (error.response && error.response.status === 403) {
return { success: false, message: 'GitHub API rate limit exceeded. Try again later.' };
}
console.error('Check for updates error:', error.message);
return { success: false, message: error.message || 'Failed to check for updates' };
}
});
ipcMain.handle('download-and-install-update', async (event, { downloadUrl }) => {
try {
// Determine the path to the currently running executable
const currentExePath = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
const currentDir = getAppDirectory();
const currentExeName = path.basename(currentExePath);
// Check write permission on the app directory
try {
fs.accessSync(currentDir, fs.constants.W_OK);
} catch {
return { success: false, message: 'No write permission to the application directory. Try running as administrator.' };
}
const tempDir = os.tmpdir();
const tempExePath = path.join(tempDir, `AltaCameraProxy-update-${Date.now()}.exe`);
// Download the file with progress reporting
await new Promise((resolve, reject) => {
httpsGetFollowRedirects(downloadUrl, (res, err) => {
if (err) return reject(err);
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`Download failed with status ${res.statusCode}`));
}
const totalSize = parseInt(res.headers['content-length'], 10) || 0;
let downloadedSize = 0;
const fileStream = fs.createWriteStream(tempExePath);
res.on('data', (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0 && mainWindow && !mainWindow.isDestroyed()) {
const percent = Math.round((downloadedSize / totalSize) * 100);
mainWindow.webContents.send('update-download-progress', { percent, downloadedSize, totalSize });
}
});
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve();
});
fileStream.on('error', (err) => {
fs.unlink(tempExePath, () => {});
reject(err);
});
});
});
// Verify downloaded file size (sanity check: > 10MB for an Electron portable exe)
const stats = fs.statSync(tempExePath);
if (stats.size < 10 * 1024 * 1024) {
fs.unlinkSync(tempExePath);
return { success: false, message: 'Downloaded file is too small — update may be corrupt.' };
}
// Create batch script to replace the exe after this process exits
const batchPath = path.join(tempDir, `apt-update-${Date.now()}.bat`);
const pid = process.pid;
const batchContent = `@echo off\r\ntitle APT-Updater\r\necho Waiting for Alta Proxy Tool to close...\r\n:waitloop\r\ntasklist /fi "PID eq ${pid}" 2>nul | find "${pid}" >nul\r\nif not errorlevel 1 (\r\n timeout /t 1 /nobreak >nul\r\n goto waitloop\r\n)\r\necho Applying update...\r\ncopy /y "${tempExePath}" "${path.join(currentDir, currentExeName)}"\r\nif errorlevel 1 (\r\n echo Update failed! Could not copy new version.\r\n pause\r\n del "${tempExePath}" >nul 2>&1\r\n del "%~f0" >nul 2>&1\r\n exit /b 1\r\n)\r\necho Update complete. Launching new version...\r\nstart "" "${path.join(currentDir, currentExeName)}"\r\ndel "${tempExePath}" >nul 2>&1\r\ndel "%~f0" >nul 2>&1\r\n`;
fs.writeFileSync(batchPath, batchContent);
// Spawn the updater batch script detached
let updater;
try {
updater = spawn('cmd', ['/c', batchPath], {
detached: true,
stdio: 'ignore',
windowsHide: true
});
updater.unref();
} catch (spawnError) {
console.error('Failed to spawn updater:', spawnError);
try { fs.unlinkSync(tempExePath); } catch {}
try { fs.unlinkSync(batchPath); } catch {}
return { success: false, message: 'Failed to start updater process.' };
}
// Quit the app after a delay to let the IPC response return to renderer
setTimeout(() => {
app.quit();
}, 1500);
return { success: true, message: 'Update is being installed. The app will restart shortly.' };
} catch (error) {
console.error('Download and install update error:', error);
return { success: false, message: error.message || 'Failed to download and install update' };
}
});
+2
View File
@@ -6,6 +6,8 @@
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"dev": "electron . --dev", "dev": "electron . --dev",
"test": "node --test test/*.test.js",
"check": "node --check main.js && node --check preload.js && node --check renderer.js && node --check src/electron-runtime.js && node --check chrome-extension/popup.js && npm test",
"build": "electron-builder --win --publish=never", "build": "electron-builder --win --publish=never",
"build-test": "electron-builder --win --dir", "build-test": "electron-builder --win --dir",
"build-kit": "powershell -ExecutionPolicy Bypass -File build-kit.ps1", "build-kit": "powershell -ExecutionPolicy Bypass -File build-kit.ps1",
+22 -34
View File
@@ -1,38 +1,26 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use function onConnectionStateChanged(callback) {
// the ipcRenderer without exposing the entire object if (typeof callback !== 'function') return () => {};
contextBridge.exposeInMainWorld('electronAPI', { const listener = (_event, state) => callback(state);
getDevices: (params) => ipcRenderer.invoke('api-get-devices', params), ipcRenderer.on('connection-state-changed', listener);
getDeviceSites: (params) => ipcRenderer.invoke('api-get-device-sites', params), return () => ipcRenderer.removeListener('connection-state-changed', listener);
getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params), }
// Camera proxy functionality contextBridge.exposeInMainWorld('electronAPI', Object.freeze({
launchCookieCameraProxy: (params) => ipcRenderer.invoke('camera-proxy-cookie-launch', params), getDevices: () => ipcRenderer.invoke('get-devices'),
stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }), getDeviceSites: () => ipcRenderer.invoke('get-device-sites'),
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
// Extension cookie bridge (push from main process) launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId),
onExtensionCookie: (callback) => { stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
ipcRenderer.on('extension-cookie-received', (event, data) => { disconnect: () => ipcRenderer.invoke('disconnect'),
try { getConnectionState: () => ipcRenderer.invoke('get-connection-state'),
callback(data);
} catch (error) {
console.error('Extension cookie handler error:', error);
}
});
},
// Self-update functionality
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadAndInstallUpdate: (params) => ipcRenderer.invoke('download-and-install-update', params), openFixedReleasesPage: () => ipcRenderer.invoke('open-fixed-releases-page'),
getCurrentVersion: () => ipcRenderer.invoke('get-current-version'), rotatePairing: () => ipcRenderer.invoke('rotate-pairing'),
onUpdateDownloadProgress: (callback) => { revokePairing: () => ipcRenderer.invoke('revoke-pairing'),
ipcRenderer.on('update-download-progress', (event, data) => { getPairingStatus: () => ipcRenderer.invoke('get-pairing-status'),
try { onConnectionStateChanged,
callback(data); }));
} catch (error) {
console.error('Update download progress handler error:', error);
}
});
}
});
+279 -735
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -133,7 +133,7 @@ class BridgeAuth {
authenticateRequest(request) { authenticateRequest(request) {
if (!request || typeof request !== 'object') return false; if (!request || typeof request !== 'object') return false;
const headers = request.headers || {}; const headers = request.headers || {};
const secret = headers['x-apt-pairing-secret']; const secret = headers['x-apt-pairing'];
return this.authenticate({ origin: headers.origin, secret }); return this.authenticate({ origin: headers.origin, secret });
} }
} }
+300
View File
@@ -0,0 +1,300 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const {
APT_EXTENSION_ORIGIN,
BridgeAuth,
BridgeAuthError,
createRequestLimiter,
readJsonBody,
} = require('./bridge-auth');
const { RELEASES_PAGE_URL } = require('./update-policy');
const { validateDeviceId } = require('./proxy-launch');
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
function safeErrorMessage(error, fallback) {
const allowed = new Set([
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'SPAWN_FAILED',
]);
return error && allowed.has(error.code) && typeof error.message === 'string'
? error.message
: fallback;
}
function loadPairingEnvelope(envelopePath) {
if (!fs.existsSync(envelopePath)) return null;
const bytes = fs.readFileSync(envelopePath);
if (bytes.length === 0 || bytes.length > 4096) throw new Error('Invalid pairing envelope file');
const parsed = JSON.parse(bytes.toString('utf8'));
// BridgeAuth performs the authoritative envelope schema validation.
return new BridgeAuth({ envelope: parsed }).envelope;
}
function atomicWritePairingEnvelope(envelopePath, envelope) {
const directory = path.dirname(envelopePath);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const temporaryPath = `${envelopePath}.${process.pid}.${Date.now()}.tmp`;
const payload = `${JSON.stringify(envelope)}\n`;
try {
fs.writeFileSync(temporaryPath, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
fs.renameSync(temporaryPath, envelopePath);
fs.chmodSync(envelopePath, 0o600);
} catch (error) {
try { fs.unlinkSync(temporaryPath); } catch {}
throw error;
}
}
class PairingController {
constructor({ envelopePath, bridgeAuth } = {}) {
if (typeof envelopePath !== 'string' || envelopePath.length === 0) {
throw new TypeError('PairingController requires an envelope path');
}
this.envelopePath = envelopePath;
if (bridgeAuth) {
this.bridgeAuth = bridgeAuth;
} else {
let envelope = null;
try {
envelope = loadPairingEnvelope(envelopePath);
} catch {
try { fs.unlinkSync(envelopePath); } catch {}
}
this.bridgeAuth = new BridgeAuth({ envelope });
}
}
initialize() {
if (this.bridgeAuth.envelope) return { paired: true };
return this.rotate();
}
rotate() {
const { secret, envelope } = this.bridgeAuth.rotate();
try {
atomicWritePairingEnvelope(this.envelopePath, envelope);
} catch (error) {
this.bridgeAuth.revoke();
throw error;
}
return { paired: true, secret };
}
revoke() {
this.bridgeAuth.revoke();
try {
fs.unlinkSync(this.envelopePath);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
return { paired: false };
}
getStatus() {
return { paired: Boolean(this.bridgeAuth.envelope) };
}
}
function writeJson(response, statusCode, payload) {
if (response.writableEnded) return;
response.writeHead(statusCode, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
response.end(JSON.stringify(payload));
}
function createBridgeHandler({
bridgeAuth,
sessionStore,
onConnectionStateChanged = () => {},
limiter = createRequestLimiter({ maxConcurrent: 4 }),
maxBodyBytes,
deadlineMs,
} = {}) {
if (!bridgeAuth || typeof bridgeAuth.authenticateRequest !== 'function') {
throw new TypeError('Bridge handler requires bridge authentication');
}
if (!sessionStore || typeof sessionStore.establish !== 'function') {
throw new TypeError('Bridge handler requires a session store');
}
return async function handleBridgeRequest(request, response) {
const origin = request && request.headers && request.headers.origin;
if (origin !== APT_EXTENSION_ORIGIN) {
writeJson(response, 403, { success: false, message: 'Forbidden' });
return;
}
response.setHeader('Access-Control-Allow-Origin', APT_EXTENSION_ORIGIN);
response.setHeader('Vary', 'Origin');
response.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Pairing');
response.setHeader('Access-Control-Max-Age', '600');
if (request.method === 'OPTIONS') {
response.writeHead(204, { 'Cache-Control': 'no-store' });
response.end();
return;
}
if (request.method !== 'POST' || request.url !== '/cookie') {
writeJson(response, 404, { success: false, message: 'Not found' });
return;
}
if (!bridgeAuth.authenticateRequest(request)) {
writeJson(response, 403, { success: false, message: 'Forbidden' });
return;
}
try {
await limiter.run(async () => {
const data = await readJsonBody(request, { maxBytes: maxBodyBytes, deadlineMs });
const state = sessionStore.establish(data.deploymentUrl, data.cookieValue);
onConnectionStateChanged({ connected: state.connected, origin: state.origin });
});
writeJson(response, 200, { success: true, message: 'Session received' });
} catch (error) {
const statusCode = error instanceof BridgeAuthError
? error.statusCode
: error && error.code === 'INVALID_SESSION_COOKIE'
? 400
: 400;
writeJson(response, statusCode, { success: false, message: statusCode === 429 ? 'Too many requests' : 'Invalid request' });
}
};
}
class AppRuntime {
constructor({
sessionStore,
altaClient,
proxyManager,
checkForUpdate,
currentVersion,
openExternal,
} = {}) {
this.sessionStore = sessionStore;
this.altaClient = altaClient;
this.proxyManager = proxyManager;
this.checkForUpdatePolicy = checkForUpdate;
this.currentVersion = currentVersion;
this.openExternal = openExternal;
this.allowedDeviceIds = new Set();
this.proxyByDevice = new Map();
}
async getDevices() {
try {
const devices = await this.altaClient.getDevices();
this.allowedDeviceIds = new Set();
for (const device of devices) {
const candidate = device && (device.guid || device.id);
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {}
}
return { success: true, devices };
} catch (error) {
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
}
}
async getDeviceSites() {
try {
return { success: true, sites: await this.altaClient.getDeviceSites() };
} catch (error) {
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
}
}
async getAuthInfo() {
try {
return { success: true, authInfo: await this.altaClient.getAuthInfo() };
} catch (error) {
return { success: false, message: safeErrorMessage(error, 'Failed to get authentication information') };
}
}
async launchProxy(deviceId) {
try {
const validatedId = validateDeviceId(deviceId);
if (!this.allowedDeviceIds.has(validatedId)) {
return { success: false, message: 'Select a device from the current Alta device list.' };
}
if (this.proxyByDevice.has(validatedId)) {
return { success: false, message: 'A proxy is already running for this device.' };
}
const session = this.sessionStore.requireSession();
const result = this.proxyManager.launchProxy({
deploymentHost: new URL(session.origin).hostname,
cookie: session.cookie,
deviceId: validatedId,
});
this.proxyByDevice.set(validatedId, result.processId);
return { success: true, processId: result.processId, deviceId: validatedId, status: result.status };
} catch (error) {
return { success: false, message: safeErrorMessage(error, 'Failed to launch proxy') };
}
}
async stopProxy(key) {
let deviceId = null;
let processId = null;
if (typeof key === 'string') {
try { deviceId = validateDeviceId(key); } catch { return { success: false, message: 'Invalid proxy key.' }; }
processId = this.proxyByDevice.get(deviceId);
} else if (Number.isSafeInteger(key) && key > 0) {
processId = key;
for (const [candidateDeviceId, candidateProcessId] of this.proxyByDevice) {
if (candidateProcessId === processId) deviceId = candidateDeviceId;
}
}
if (!processId || !deviceId) return { success: false, message: 'Proxy process is not owned by this app.' };
const result = this.proxyManager.stopProxy(processId);
if (result.success) this.proxyByDevice.delete(deviceId);
return { ...result, deviceId };
}
disconnect() {
this.sessionStore.clear();
this.allowedDeviceIds.clear();
return this.getConnectionState();
}
getConnectionState() {
const state = this.sessionStore.describe();
return {
connected: state.connected,
origin: state.origin,
activeProxies: Array.from(this.proxyByDevice, ([deviceId, processId]) => ({ deviceId, processId })),
};
}
async checkForUpdates() {
try {
const result = await this.checkForUpdatePolicy({ currentVersion: this.currentVersion });
return { success: true, ...result };
} catch {
return { success: false, message: 'Could not securely check for updates.' };
}
}
async openFixedReleasesPage() {
await this.openExternal(RELEASES_PAGE_URL);
return { success: true };
}
}
module.exports = {
AppRuntime,
PAIRING_ENVELOPE_FILENAME,
PairingController,
atomicWritePairingEnvelope,
createBridgeHandler,
loadPairingEnvelope,
};
+42 -50
View File
@@ -532,7 +532,47 @@ button:disabled {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Update Modal */ /* Bridge pairing */
.section-help {
color: var(--text-secondary);
font-size: 13px;
margin: 0 0 14px;
}
.pairing-section .paired {
color: var(--success);
font-weight: 600;
}
.pairing-section .unpaired {
color: var(--warning);
font-weight: 600;
}
.pairing-secret-row {
margin: 14px 0;
}
.pairing-secret-row label,
.pairing-secret-row small {
display: block;
color: var(--text-secondary);
margin-bottom: 6px;
}
.pairing-secret-row input {
width: 100%;
box-sizing: border-box;
font-family: monospace;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px;
margin-bottom: 6px;
}
/* Update notification */
.update-modal-overlay { .update-modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -595,55 +635,7 @@ button:disabled {
.update-modal-message { .update-modal-message {
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 12px 0; margin: 0;
}
.update-modal-notes {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
padding: 12px;
background: var(--bg-primary);
border-radius: 4px;
border: 1px solid var(--border);
}
.update-modal-notes:empty {
display: none;
}
.update-progress-container {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.update-progress-track {
flex: 1;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
}
.update-progress-fill {
height: 100%;
background: var(--accent-primary);
border-radius: 4px;
transition: width 0.3s ease;
}
.update-progress-text {
font-size: 12px;
font-weight: bold;
color: var(--text-secondary);
min-width: 36px;
text-align: right;
} }
.update-modal-footer { .update-modal-footer {
+1 -1
View File
@@ -129,7 +129,7 @@ test('Send to APT uses the paired secret header and exact endpoint', async () =>
assert.equal(harness.fetchCalls.length, 1); assert.equal(harness.fetchCalls.length, 1);
const [url, request] = harness.fetchCalls[0]; const [url, request] = harness.fetchCalls[0];
assert.equal(url, 'http://127.0.0.1:18247/cookie'); assert.equal(url, 'http://127.0.0.1:18247/cookie');
assert.equal(request.headers['X-APT-Pairing-Secret'], 'A'.repeat(43)); assert.equal(request.headers['X-APT-Pairing'], 'A'.repeat(43));
assert.deepEqual(JSON.parse(request.body), { assert.deepEqual(JSON.parse(request.body), {
deploymentUrl: 'https://customer.avasecurity.com', deploymentUrl: 'https://customer.avasecurity.com',
cookieValue: 'sensitive-va-token' cookieValue: 'sensitive-va-token'
+208
View File
@@ -0,0 +1,208 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { PassThrough } = require('node:stream');
const {
AppRuntime,
PairingController,
createBridgeHandler,
loadPairingEnvelope,
} = require('../src/electron-runtime');
const { APT_EXTENSION_ORIGIN, BridgeAuth } = require('../src/bridge-auth');
const { createSessionStore } = require('../src/session-store');
const ROOT = path.join(__dirname, '..');
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
function responseHarness() {
return {
statusCode: 0,
headers: {},
body: '',
setHeader(name, value) { this.headers[name.toLowerCase()] = value; },
writeHead(statusCode, headers = {}) {
this.statusCode = statusCode;
for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
},
end(chunk = '') { this.body += chunk; },
};
}
function requestHarness({ method = 'POST', origin = APT_EXTENSION_ORIGIN, secret, body = '{}' } = {}) {
const request = new PassThrough();
request.method = method;
request.url = '/cookie';
request.headers = { origin };
if (secret !== undefined) request.headers['x-apt-pairing'] = secret;
process.nextTick(() => request.end(body));
return request;
}
test('runtime keeps Alta credentials in main-owned modules and exposes only non-secret state', async () => {
const sessionStore = createSessionStore();
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
const calls = [];
const proxyManager = {
launchProxy(request) { calls.push(request); return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' }; },
stopProxy(processId) { calls.push({ processId }); return { success: true, processId, status: 'stop-requested' }; },
listTrackedProxies() { return []; },
};
const runtime = new AppRuntime({
sessionStore,
altaClient: {
getDevices: async () => [{ guid: '550e8400-e29b-41d4-a716-446655440000' }],
getDeviceSites: async () => [],
getAuthInfo: async () => ({ user: 'safe' }),
},
proxyManager,
checkForUpdate: async ({ currentVersion }) => ({ status: 'up-to-date', currentVersion }),
currentVersion: '1.0.0',
openExternal: async () => {},
});
assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] });
const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000');
assert.equal(launched.success, true);
assert.deepEqual(calls[0], {
deploymentHost: 'customer.avasecurity.com',
cookie: 'top-secret-cookie',
deviceId: '550e8400-e29b-41d4-a716-446655440000',
});
assert.deepEqual(runtime.getConnectionState(), {
connected: true,
origin: 'https://customer.avasecurity.com',
activeProxies: [{ deviceId: '550e8400-e29b-41d4-a716-446655440000', processId: 91 }],
});
assert.equal(JSON.stringify(launched).includes('top-secret-cookie'), false);
assert.equal(JSON.stringify(runtime.getConnectionState()).includes('top-secret-cookie'), false);
assert.equal((await runtime.stopProxy('550e8400-e29b-41d4-a716-446655440000')).success, true);
assert.deepEqual(calls[1], { processId: 91 });
assert.equal((await runtime.stopProxy(999)).success, false);
});
test('bridge rejects unknown preflight and unauthenticated requests before reading their body', async () => {
const auth = new BridgeAuth();
const secret = auth.rotate().secret;
const sessionStore = createSessionStore();
let stateNotifications = 0;
const handler = createBridgeHandler({
bridgeAuth: auth,
sessionStore,
onConnectionStateChanged: () => { stateNotifications += 1; },
});
const unknownPreflight = requestHarness({ method: 'OPTIONS', origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' });
const unknownResponse = responseHarness();
await handler(unknownPreflight, unknownResponse);
assert.equal(unknownResponse.statusCode, 403);
assert.equal(unknownResponse.headers['access-control-allow-origin'], undefined);
const unauthenticated = requestHarness({ body: '{'.repeat(1000) });
let dataRead = false;
unauthenticated.on('data', () => { dataRead = true; });
const unauthenticatedResponse = responseHarness();
await handler(unauthenticated, unauthenticatedResponse);
assert.equal(unauthenticatedResponse.statusCode, 403);
assert.equal(dataRead, false);
const allowedPreflight = requestHarness({ method: 'OPTIONS', secret });
const allowedResponse = responseHarness();
await handler(allowedPreflight, allowedResponse);
assert.equal(allowedResponse.statusCode, 204);
assert.equal(allowedResponse.headers['access-control-allow-origin'], APT_EXTENSION_ORIGIN);
assert.match(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/);
const accepted = requestHarness({
secret,
body: JSON.stringify({ deploymentUrl: 'https://customer.avasecurity.com', cookieValue: 'valid-cookie' }),
});
const acceptedResponse = responseHarness();
await handler(accepted, acceptedResponse);
assert.equal(acceptedResponse.statusCode, 200);
assert.deepEqual(sessionStore.describe(), { connected: true, origin: 'https://customer.avasecurity.com' });
assert.equal(stateNotifications, 1);
assert.equal(acceptedResponse.body.includes('valid-cookie'), false);
});
test('pairing envelope persists with restrictive permissions and secrets are returned once', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-'));
const envelopePath = path.join(directory, 'bridge-pairing.json');
const controller = new PairingController({ envelopePath });
const first = controller.initialize();
assert.equal(first.paired, true);
assert.match(first.secret, /^[A-Za-z0-9_-]{43}$/);
assert.deepEqual(controller.getStatus(), { paired: true });
const persisted = loadPairingEnvelope(envelopePath);
assert.equal(Object.values(persisted).includes(first.secret), false);
if (process.platform !== 'win32') assert.equal(fs.statSync(envelopePath).mode & 0o777, 0o600);
const rotated = controller.rotate();
assert.match(rotated.secret, /^[A-Za-z0-9_-]{43}$/);
assert.notEqual(rotated.secret, first.secret);
assert.deepEqual(controller.getStatus(), { paired: true });
controller.revoke();
assert.deepEqual(controller.getStatus(), { paired: false });
assert.equal(fs.existsSync(envelopePath), false);
});
test('update runtime is check-only and opens only the fixed GitPeji releases page', async () => {
const opened = [];
const runtime = new AppRuntime({
sessionStore: createSessionStore(),
altaClient: {},
proxyManager: {},
checkForUpdate: async ({ currentVersion }) => ({ status: 'update-available', currentVersion, latestVersion: '1.1.0' }),
currentVersion: '1.0.0',
openExternal: async (url) => { opened.push(url); },
});
assert.deepEqual(await runtime.checkForUpdates(), {
success: true,
status: 'update-available',
currentVersion: '1.0.0',
latestVersion: '1.1.0',
});
assert.deepEqual(await runtime.openFixedReleasesPage(), { success: true });
assert.deepEqual(opened, ['https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases']);
});
test('preload and renderer expose only narrow, credential-free contracts', () => {
const preload = read('preload.js');
const renderer = read('renderer.js');
const html = read('index.html');
const expectedMethods = [
'getDevices', 'getDeviceSites', 'getAuthInfo', 'launchProxy', 'stopProxy', 'disconnect',
'getConnectionState', 'checkForUpdates', 'openFixedReleasesPage', 'rotatePairing',
'revokePairing', 'getPairingStatus', 'onConnectionStateChanged',
];
for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`));
assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/);
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
assert.match(renderer, /openFixedReleasesPage/);
assert.match(html, /Bridge Pairing/);
});
test('source policy removes legacy credential IPC, shell launch, broad kill, and executable updater', () => {
const production = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/electron-runtime.js']
.map(read).join('\n');
const forbidden = [
/sanitizeBatchInput/, /taskkill/i, /\.bat\b/i, /download-and-install-update/,
/httpsGetFollowRedirects/, /api\.github\.com/i, /github releases/i,
/apt-local-bridge-token/, /X-APT-Token/, /cookie-proxy-/,
];
for (const pattern of forbidden) assert.doesNotMatch(production, pattern);
assert.doesNotMatch(read('main.js'), /axios/);
assert.match(read('main.js'), /event\.sender === webContents/);
assert.match(read('main.js'), /frame === webContents\.mainFrame/);
assert.match(read('main.js'), /pathToFileURL\(path\.join\(__dirname, 'index\.html'\)\)/);
assert.match(read('main.js'), /127\.0\.0\.1/);
assert.match(read('main.js'), /18247/);
assert.match(read('main.js'), /shell\.openExternal/);
});