Compare commits
10 Commits
1215b95df2
...
8dad96c27d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dad96c27d | |||
| 808698dc46 | |||
| 582fc46914 | |||
| 08498653eb | |||
| cf75ea9bc2 | |||
| 2f492b662d | |||
| b6df6f1a66 | |||
| b503b5777a | |||
| f5cfdd8560 | |||
| ceaa0bfdce |
@@ -0,0 +1,36 @@
|
||||
name: APT build checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-checks:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run source policy and tests
|
||||
run: npm run check
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: npm run audit:prod
|
||||
|
||||
- name: Build unpacked Windows application
|
||||
run: npm run build-test
|
||||
|
||||
- name: Verify unpacked build and extension policy
|
||||
run: node scripts/verify-kit.js --build dist/win-unpacked
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Deploy GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,112 +1,50 @@
|
||||
# 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
|
||||
|
||||
- **GitHub**: https://github.com/PageZ948/Alta-Proxy-Tool (private)
|
||||
- **Branch**: master
|
||||
- **Git identity**: Zac <zpage948@gmail.com> (repo-local config)
|
||||
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.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Run the app
|
||||
npm run dev # Run with DevTools open (--dev flag)
|
||||
npm run build # Build portable Windows .exe (output: dist/)
|
||||
npm run build-test # Build to directory without packaging
|
||||
npm start
|
||||
npm run dev
|
||||
npm test
|
||||
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 main process: IPC handlers, API calls (axios),
|
||||
cookie proxy process spawning, local HTTP cookie server
|
||||
preload.js → contextBridge exposing window.electronAPI with IPC wrappers
|
||||
renderer.js → All UI logic: DOM manipulation, state management, event handlers
|
||||
index.html → Static HTML shell, no inline scripts (CSP enforced)
|
||||
styles.css → Dark theme using CSS custom properties
|
||||
```
|
||||
- `main.js`: Electron adapter, lifecycle, trusted IPC registration, fixed bridge listener
|
||||
- `src/electron-runtime.js`: runtime orchestration, pairing persistence, bridge request contract
|
||||
- `src/session-store.js`: in-memory credential boundary
|
||||
- `src/alta-client.js`: bounded same-origin Alta API client
|
||||
- `src/proxy-launch.js`: Windows direct-spawn owned-child manager
|
||||
- `src/update-policy.js`: GitPeji check-only policy
|
||||
- `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
|
||||
|
||||
```
|
||||
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/`.
|
||||
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.
|
||||
|
||||
@@ -1,186 +1,69 @@
|
||||
# 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
|
||||
- **API Integration**: Device discovery via Alta Video API using cookie auth
|
||||
- **Camera Proxy Management**: Launch and manage camera proxy connections
|
||||
- **Device Filtering**: Automatically filters to show only local (non-cloud) cameras
|
||||
- **Device Search**: Quick search functionality to find cameras by name, ID, IP, or model
|
||||
- **Real-time Status**: Live connection status and device online/offline indicators
|
||||
- **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
|
||||
- `main.js` owns the in-memory `SessionStore`, `AltaClient`, `ProxyProcessManager`, bridge pairing envelope, and update checker.
|
||||
- The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value.
|
||||
- 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.
|
||||
- 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.
|
||||
- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. Its exact arguments are deployment host, non-secret Alta username/email, and selected device UUID; no session bearer, password, or 2FA value reaches the command line. Stop actions can target only children owned by this app.
|
||||
- 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.
|
||||
|
||||
## Prerequisites
|
||||
## Requirements
|
||||
|
||||
- **Node.js** (version 14 or higher)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Google Chrome** (for the authentication extension)
|
||||
- **Windows OS** (required for camera proxy executable)
|
||||
- **aware-cam-proxy.exe** (camera proxy executable) — must be placed in the application directory
|
||||
- **An active Alta Video session** in Chrome (logged in to your deployment)
|
||||
- Windows (current supported runtime scope)
|
||||
- Node.js 22.12 or newer and npm for development
|
||||
- Chrome with the bundled extension loaded unpacked
|
||||
- `aware-cam-proxy.exe` beside the development app or packaged portable executable
|
||||
- An active Alta Video login in Chrome
|
||||
|
||||
## Installation
|
||||
|
||||
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
|
||||
## Setup and pairing
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Or for development mode with DevTools:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
1. Open `chrome://extensions`, enable Developer mode, and load `chrome-extension/` unpacked.
|
||||
2. Start APT. Under **Bridge Pairing**, copy the one-time secret.
|
||||
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, enter your Alta username/email, and choose **Start Proxy**.
|
||||
6. Complete the password and 2FA prompts in the helper's visible Windows console. APT does not collect or pass those secrets.
|
||||
|
||||
### 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`)
|
||||
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
|
||||
|
||||
### Launching Camera Proxy
|
||||
|
||||
1. **Connect via Chrome extension** (above)
|
||||
2. **Select a device** from the left sidebar
|
||||
3. **Click "Start Camera Proxy"**
|
||||
4. A command prompt window will open with the proxy connection
|
||||
|
||||
### Updating
|
||||
|
||||
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.
|
||||
|
||||
- **Manual check**: Click "Check for Updates" in the top-right corner
|
||||
- **Install**: The update modal shows release notes — click "Install Update" to download and replace the current executable
|
||||
- The app will quit, swap the `.exe`, and relaunch automatically
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
- **Device List**: `GET /api/v1/devices` — Retrieve all devices
|
||||
- **Auth Info**: `GET /api/v1/auth` — Verify authentication status
|
||||
|
||||
## 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
|
||||
## Development and verification
|
||||
|
||||
```bash
|
||||
npm test # Node test suite, including synthetic runtime contracts
|
||||
npm run check # Syntax checks plus tests
|
||||
npm run audit:prod
|
||||
npm run build-test
|
||||
```
|
||||
|
||||
Tests use synthetic sessions/transports/processes only. Never add a real Alta tenant URL or session value to fixtures, logs, screenshots, or commits.
|
||||
|
||||
Core files:
|
||||
|
||||
- `main.js` — Electron lifecycle, trusted-sender IPC, fixed loopback server
|
||||
- `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
|
||||
- `src/proxy-launch.js` — fixed shell-free helper process management
|
||||
- `src/update-policy.js` — exact GitPeji check-only release policy
|
||||
- `preload.js` — narrow context bridge
|
||||
- `renderer.js`, `index.html`, `styles.css` — non-secret UI
|
||||
- `chrome-extension/` — stable-ID paired cookie sender
|
||||
- `test/` — pure and end-to-end contract tests
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Build portable Windows executable
|
||||
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.
|
||||
|
||||
## 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.
|
||||
CI produces an unpacked Windows build only for verification. It does not publish, release, push, deploy, or sign artifacts. Windows code signing and verification with the approved certificate remain mandatory manual release gates; a successful build must not be represented as signed.
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Alta Proxy Tool Bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "Send Alta session cookies to the Alta Proxy Tool desktop app.",
|
||||
"permissions": ["cookies", "activeTab", "clipboardWrite"],
|
||||
"version": "1.1.0",
|
||||
"description": "Send Alta session cookies to a paired Alta Proxy Tool desktop app.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4EZdkSgOsyiy5DRe0JkX+BpK94FpMjBU59NVIqDPO8QBDwvqNDWT/UjqHK/0aqSxzed5KibX6MdAvc495+u1sCybFdjDdXyBewEvg+PDqGiJketlZKC9dcR1RXHuPgAoM3NaNbMb3TqYcS9J4iGq0UwadxubkQrEcPiuyR6oriOkop8q9/5DWGb15wOGmiCuVmlXfUjNJIvNBm9P/ZHtgFBYDI2PuSSI5GI4j04VFpEyfNlFCrpi8GQ7bYZzezigZWXRjhhNwkx39bNHlkAWYa8XGZseCpKKvi0EaeCoBPjoYSAt161SM1dqX+/UC61/sLOU/SpDB1SYGTm5DC+7wIDAQAB",
|
||||
"permissions": ["cookies", "activeTab", "clipboardWrite", "storage"],
|
||||
"host_permissions": [
|
||||
"https://*.avasecurity.com/*",
|
||||
"https://*.avigilon.com/*",
|
||||
"http://127.0.0.1:18247/*"
|
||||
],
|
||||
"options_page": "options.html",
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.options-card {
|
||||
max-width: 560px;
|
||||
margin: 48px auto;
|
||||
padding: 28px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
background: #252526;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 { margin-top: 0; color: #75afff; }
|
||||
h2 { margin-bottom: 6px; font-size: 16px; }
|
||||
p { line-height: 1.5; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #1e1e1e;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #0e7afe;
|
||||
outline: 2px solid rgba(14, 122, 254, 0.3);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-btn { border: 0; background: #0e7afe; }
|
||||
.danger-btn { border: 1px solid #f44336; background: transparent; color: #ff7b72; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
|
||||
.status {
|
||||
min-height: 20px;
|
||||
margin-top: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.success,
|
||||
.paired { color: #7bd67e; }
|
||||
.status.error,
|
||||
.unpaired { color: #ffc36d; }
|
||||
|
||||
.paired-controls {
|
||||
margin-top: 24px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin-top: 24px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
|
||||
<title>Pair Alta Proxy Tool</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="options-card">
|
||||
<h1>Pair with Alta Proxy Tool</h1>
|
||||
<p>In the desktop app, create a one-time pairing secret. Paste it below on this computer. Treat it like a password.</p>
|
||||
<form id="pairingForm">
|
||||
<label for="pairingSecret">One-time pairing secret</label>
|
||||
<input id="pairingSecret" name="pairingSecret" type="password" inputmode="text" autocomplete="off" spellcheck="false" required>
|
||||
<button type="submit" class="primary-btn">Pair extension</button>
|
||||
</form>
|
||||
<div id="pairingStatus" class="status" role="status" aria-live="polite"></div>
|
||||
<section class="paired-controls">
|
||||
<h2>Current pairing</h2>
|
||||
<p id="currentState">Checking...</p>
|
||||
<button id="forgetBtn" type="button" class="danger-btn" disabled>Forget pairing</button>
|
||||
</section>
|
||||
<p class="privacy-note">The secret is stored only in Chrome extension local storage. It is never displayed again or copied automatically.</p>
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
const PAIRING_STORAGE_KEY = 'aptPairingSecret';
|
||||
const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
const pairingForm = document.getElementById('pairingForm');
|
||||
const pairingSecretInput = document.getElementById('pairingSecret');
|
||||
const pairingStatus = document.getElementById('pairingStatus');
|
||||
const currentState = document.getElementById('currentState');
|
||||
const forgetBtn = document.getElementById('forgetBtn');
|
||||
|
||||
function setStatus(message, type) {
|
||||
pairingStatus.textContent = message;
|
||||
pairingStatus.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function renderPairingState(paired) {
|
||||
currentState.textContent = paired ? 'Paired on this Chrome profile.' : 'Not paired.';
|
||||
currentState.className = paired ? 'paired' : 'unpaired';
|
||||
forgetBtn.disabled = !paired;
|
||||
}
|
||||
|
||||
async function refreshPairingState() {
|
||||
const stored = await chrome.storage.local.get(PAIRING_STORAGE_KEY);
|
||||
const candidate = stored && stored[PAIRING_STORAGE_KEY];
|
||||
renderPairingState(PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : ''));
|
||||
}
|
||||
|
||||
pairingForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const candidate = pairingSecretInput.value.trim();
|
||||
pairingSecretInput.value = '';
|
||||
|
||||
if (!PAIRING_SECRET_PATTERN.test(candidate)) {
|
||||
setStatus('That secret is not valid. Generate a new pairing secret in APT and paste it exactly.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.storage.local.set({ [PAIRING_STORAGE_KEY]: candidate });
|
||||
setStatus('Pairing saved. You can now use Send to APT from an Alta tab.', 'success');
|
||||
renderPairingState(true);
|
||||
} catch {
|
||||
setStatus('Chrome could not save the pairing. Try again.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
forgetBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await chrome.storage.local.remove(PAIRING_STORAGE_KEY);
|
||||
renderPairingState(false);
|
||||
setStatus('Pairing forgotten. Revoke or rotate it in APT as well.', 'success');
|
||||
} catch {
|
||||
setStatus('Chrome could not forget the pairing. Try again.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
refreshPairingState().catch(() => {
|
||||
renderPairingState(false);
|
||||
setStatus('Chrome could not read pairing state.', 'error');
|
||||
});
|
||||
+70
-59
@@ -2,120 +2,131 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
background: #1E1E1E;
|
||||
color: #E0E0E0;
|
||||
font-size: 14px;
|
||||
min-width: 300px;
|
||||
min-width: 320px;
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
font: 14px/1.4 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.popup-container { padding: 16px; }
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
color: #0e7afe;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0E7AFE;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tab-info {
|
||||
background: #2D2D30;
|
||||
border: 1px solid #3C3C3C;
|
||||
.tab-info,
|
||||
.pairing-info {
|
||||
margin-bottom: 10px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #2d2d30;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
color: #999999;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tab-info.detected {
|
||||
color: #4CAF50;
|
||||
border-color: #4CAF50;
|
||||
.tab-info.detected,
|
||||
.pairing-info.paired {
|
||||
border-color: #4caf50;
|
||||
color: #7bd67e;
|
||||
}
|
||||
|
||||
.tab-info.not-detected {
|
||||
color: #F44336;
|
||||
border-color: #F44336;
|
||||
.tab-info.not-detected,
|
||||
.pairing-info.unpaired {
|
||||
border-color: #f0a33a;
|
||||
color: #ffc36d;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-buttons { display: grid; gap: 8px; }
|
||||
|
||||
.primary-btn,
|
||||
.secondary-btn {
|
||||
display: block;
|
||||
.secondary-btn,
|
||||
.link-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background: #0E7AFE;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: #0E7AFE;
|
||||
border: 0;
|
||||
background: #0e7afe;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #2D2D30;
|
||||
border: 1px solid #0E7AFE;
|
||||
color: #E0E0E0;
|
||||
border: 1px solid #0e7afe;
|
||||
background: #2d2d30;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.primary-btn:hover:not(:disabled) {
|
||||
background: #0A5FD9;
|
||||
.link-btn {
|
||||
margin: -2px 0 10px;
|
||||
padding: 5px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #75afff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.secondary-btn:hover:not(:disabled) {
|
||||
background: #0E7AFE;
|
||||
}
|
||||
.primary-btn:hover:not(:disabled) { background: #0a5fd9; }
|
||||
.secondary-btn:hover:not(:disabled) { background: #0e7afe; }
|
||||
|
||||
.primary-btn:disabled,
|
||||
.secondary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.copy-warning {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #f0a33a;
|
||||
background: rgba(240, 163, 58, 0.08);
|
||||
color: #d7c29f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
display: none;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-msg.success,
|
||||
.status-msg.error,
|
||||
.status-msg.info { display: block; }
|
||||
|
||||
.status-msg.success {
|
||||
display: block;
|
||||
border-color: #4caf50;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4CAF50;
|
||||
border-color: #4CAF50;
|
||||
color: #7bd67e;
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
display: block;
|
||||
border-color: #f44336;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #F44336;
|
||||
border-color: #F44336;
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.status-msg.info {
|
||||
display: block;
|
||||
border-color: #0e7afe;
|
||||
background: rgba(14, 122, 254, 0.1);
|
||||
color: #0E7AFE;
|
||||
border-color: #0E7AFE;
|
||||
color: #75afff;
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@@ -3,19 +3,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src http://127.0.0.1:18247;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Alta Proxy Tool Bridge</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-container">
|
||||
<main class="popup-container">
|
||||
<h1>Alta Proxy Tool</h1>
|
||||
<div id="pairingInfo" class="pairing-info">Checking pairing...</div>
|
||||
<button id="openOptionsBtn" class="link-btn" type="button" hidden>Open pairing settings</button>
|
||||
<div id="tabInfo" class="tab-info">Checking tab...</div>
|
||||
<div class="action-buttons">
|
||||
<button id="sendBtn" class="primary-btn" disabled>Send to APT</button>
|
||||
<button id="copyBtn" class="secondary-btn" disabled>Copy VA Token</button>
|
||||
</div>
|
||||
<div id="statusMsg" class="status-msg"></div>
|
||||
<button id="sendBtn" class="primary-btn" type="button" disabled>Send to APT</button>
|
||||
<button id="copyBtn" class="secondary-btn" type="button" disabled>Copy VA Token</button>
|
||||
</div>
|
||||
<p id="copyWarning" class="copy-warning">Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.</p>
|
||||
<div id="statusMsg" class="status-msg" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+211
-99
@@ -1,130 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
const APT_URL = 'http://127.0.0.1:18247/cookie';
|
||||
const APT_TOKEN = 'apt-local-bridge-token';
|
||||
const CHALLENGE_URL = 'http://127.0.0.1:18247/challenge';
|
||||
const PAIRING_STORAGE_KEY = 'aptPairingSecret';
|
||||
const PAIRING_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const NONCE_PATTERN = PAIRING_SECRET_PATTERN;
|
||||
const BRIDGE_DEADLINE_MS = 3000;
|
||||
|
||||
const tabInfo = document.getElementById('tabInfo');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const statusMsg = document.getElementById('statusMsg');
|
||||
|
||||
let detectedOrigin = null;
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusMsg.textContent = message;
|
||||
statusMsg.className = 'status-msg ' + type;
|
||||
function base64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function setActionButtonsDisabled(disabled) {
|
||||
async function hmacBase64Url(cryptoApi, secret, value) {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await cryptoApi.subtle.importKey(
|
||||
'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||
);
|
||||
const signature = await cryptoApi.subtle.sign('HMAC', key, encoder.encode(value));
|
||||
return base64Url(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
function isSupportedDeploymentUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const host = url.hostname.toLowerCase();
|
||||
return url.protocol === 'https:' &&
|
||||
(host.endsWith('.avasecurity.com') || host.endsWith('.avigilon.com'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createPopupController({
|
||||
chromeApi,
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
fetchImpl,
|
||||
confirmCopy,
|
||||
cryptoApi
|
||||
}) {
|
||||
const tabInfo = documentApi.getElementById('tabInfo');
|
||||
const pairingInfo = documentApi.getElementById('pairingInfo');
|
||||
const sendBtn = documentApi.getElementById('sendBtn');
|
||||
const copyBtn = documentApi.getElementById('copyBtn');
|
||||
const copyWarning = documentApi.getElementById('copyWarning');
|
||||
const statusMsg = documentApi.getElementById('statusMsg');
|
||||
const openOptionsBtn = documentApi.getElementById('openOptionsBtn');
|
||||
|
||||
let detectedOrigin = null;
|
||||
let pairingSecret = null;
|
||||
let busy = false;
|
||||
|
||||
async function bridgeFetch(url, options) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), BRIDGE_DEADLINE_MS);
|
||||
try {
|
||||
return await fetchImpl(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusMsg.textContent = message;
|
||||
statusMsg.className = `status-msg ${type}`;
|
||||
}
|
||||
|
||||
function updateActions() {
|
||||
const disabled = busy || !detectedOrigin || !pairingSecret;
|
||||
sendBtn.disabled = disabled;
|
||||
copyBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
async function getVaCookieValue() {
|
||||
if (!detectedOrigin) {
|
||||
throw new Error('No Alta deployment detected.');
|
||||
}
|
||||
|
||||
const cookie = await chrome.cookies.get({ url: detectedOrigin, name: 'va' });
|
||||
|
||||
if (!cookie || !cookie.value) {
|
||||
throw new Error('No "va" session cookie found. Are you logged in?');
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
updateActions();
|
||||
}
|
||||
|
||||
async function getVaCookieValue() {
|
||||
if (!detectedOrigin) throw new Error('NO_DEPLOYMENT');
|
||||
const cookie = await chromeApi.cookies.get({ url: detectedOrigin, name: 'va' });
|
||||
if (!cookie || !cookie.value) throw new Error('MISSING_COOKIE');
|
||||
if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) {
|
||||
throw new Error('Session cookie has expired. Please log in again.');
|
||||
throw new Error('EXPIRED_COOKIE');
|
||||
}
|
||||
|
||||
return cookie.value;
|
||||
}
|
||||
|
||||
// Check the active tab on popup open
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (!tabs || tabs.length === 0) {
|
||||
tabInfo.textContent = 'No active tab found.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = tabs[0];
|
||||
let url;
|
||||
function showCookieError(error, copyOperation = false) {
|
||||
if (error && error.message === 'MISSING_COOKIE') {
|
||||
showStatus('No VA token found. Log in to Alta and try again.', 'error');
|
||||
} else if (error && error.message === 'EXPIRED_COOKIE') {
|
||||
showStatus('The VA token has expired. Log in to Alta again.', 'error');
|
||||
} else if (copyOperation) {
|
||||
showStatus('Could not copy the VA token. Check clipboard permission.', 'error');
|
||||
} else {
|
||||
showStatus('Could not reach Alta Proxy Tool on this computer.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToApt() {
|
||||
if (!detectedOrigin || !pairingSecret || busy) return;
|
||||
setBusy(true);
|
||||
let cookieValue = null;
|
||||
try {
|
||||
url = new URL(tab.url);
|
||||
} catch {
|
||||
tabInfo.textContent = 'Cannot read this tab URL.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
return;
|
||||
showStatus('Authenticating Alta Proxy Tool...', 'info');
|
||||
if (!cryptoApi || !cryptoApi.subtle || typeof cryptoApi.getRandomValues !== 'function') {
|
||||
throw new Error('CRYPTO_UNAVAILABLE');
|
||||
}
|
||||
|
||||
const hostname = url.hostname;
|
||||
const isAlta = hostname.endsWith('.avasecurity.com') || hostname.endsWith('.avigilon.com');
|
||||
|
||||
if (!isAlta) {
|
||||
tabInfo.textContent = 'This tab is not an Alta deployment.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
return;
|
||||
const clientNonce = base64Url(cryptoApi.getRandomValues(new Uint8Array(32)));
|
||||
const challengeResponse = await bridgeFetch(CHALLENGE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clientNonce })
|
||||
});
|
||||
const challenge = await challengeResponse.json();
|
||||
if (!challengeResponse.ok || !challenge || challenge.success !== true ||
|
||||
!NONCE_PATTERN.test(challenge.serverNonce) || !NONCE_PATTERN.test(challenge.serverProof)) {
|
||||
throw new Error('BRIDGE_REJECTED');
|
||||
}
|
||||
|
||||
detectedOrigin = url.origin;
|
||||
tabInfo.textContent = 'Detected: ' + hostname;
|
||||
tabInfo.className = 'tab-info detected';
|
||||
setActionButtonsDisabled(false);
|
||||
});
|
||||
|
||||
// Send cookie on button click
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
if (!detectedOrigin) return;
|
||||
|
||||
setActionButtonsDisabled(true);
|
||||
showStatus('Retrieving VA token...', 'info');
|
||||
|
||||
try {
|
||||
const cookieValue = await getVaCookieValue();
|
||||
const expectedServerProof = await hmacBase64Url(
|
||||
cryptoApi,
|
||||
pairingSecret,
|
||||
JSON.stringify(['apt-server-challenge-v1', clientNonce, challenge.serverNonce])
|
||||
);
|
||||
if (challenge.serverProof !== expectedServerProof) throw new Error('BRIDGE_AUTH_FAILED');
|
||||
|
||||
showStatus('Sending to Alta Proxy Tool...', 'info');
|
||||
|
||||
const response = await fetch(APT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-APT-Token': APT_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cookieValue = await getVaCookieValue();
|
||||
const cookieRequest = {
|
||||
clientNonce,
|
||||
serverNonce: challenge.serverNonce,
|
||||
deploymentUrl: detectedOrigin,
|
||||
cookieValue
|
||||
})
|
||||
};
|
||||
cookieRequest.proof = await hmacBase64Url(
|
||||
cryptoApi,
|
||||
pairingSecret,
|
||||
JSON.stringify(['apt-cookie-request-v1', clientNonce, challenge.serverNonce, detectedOrigin, cookieValue])
|
||||
);
|
||||
const response = await bridgeFetch(APT_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cookieRequest)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showStatus('VA token sent successfully!', 'success');
|
||||
} else {
|
||||
showStatus('Error: ' + (data.message || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('Failed to fetch')) {
|
||||
showStatus('Alta Proxy Tool is not running.', 'error');
|
||||
} else {
|
||||
showStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
if (!response.ok || !data || data.success !== true) throw new Error('BRIDGE_REJECTED');
|
||||
showStatus('VA token sent successfully.', 'success');
|
||||
} catch (error) {
|
||||
showCookieError(error);
|
||||
} finally {
|
||||
setActionButtonsDisabled(false);
|
||||
cookieValue = null;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Copy VA token on button click
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
if (!detectedOrigin) return;
|
||||
|
||||
setActionButtonsDisabled(true);
|
||||
showStatus('Retrieving VA token...', 'info');
|
||||
async function copyToken() {
|
||||
if (!detectedOrigin || !pairingSecret || busy) return;
|
||||
const confirmed = confirmCopy(
|
||||
'Copy the full VA bearer token? Clipboard history or sync may retain it. Continue only if you will paste it into a trusted destination.'
|
||||
);
|
||||
if (!confirmed) {
|
||||
showStatus('Copy cancelled. The VA token was not read.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
let cookieValue = null;
|
||||
try {
|
||||
const cookieValue = await getVaCookieValue();
|
||||
await navigator.clipboard.writeText(cookieValue);
|
||||
showStatus('VA token copied to clipboard!', 'success');
|
||||
} catch (err) {
|
||||
showStatus('Error: ' + err.message, 'error');
|
||||
cookieValue = await getVaCookieValue();
|
||||
await navigatorApi.clipboard.writeText(cookieValue);
|
||||
showStatus('VA token copied. Clear your clipboard after use.', 'success');
|
||||
} catch (error) {
|
||||
showCookieError(error, true);
|
||||
} finally {
|
||||
setActionButtonsDisabled(false);
|
||||
cookieValue = null;
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
copyWarning.textContent = 'Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.';
|
||||
sendBtn.addEventListener('click', sendToApt);
|
||||
copyBtn.addEventListener('click', copyToken);
|
||||
openOptionsBtn.addEventListener('click', () => chromeApi.runtime.openOptionsPage());
|
||||
|
||||
const stored = await chromeApi.storage.local.get(PAIRING_STORAGE_KEY);
|
||||
const candidate = stored && stored[PAIRING_STORAGE_KEY];
|
||||
if (PAIRING_SECRET_PATTERN.test(typeof candidate === 'string' ? candidate : '')) {
|
||||
pairingSecret = candidate;
|
||||
pairingInfo.textContent = 'Paired with Alta Proxy Tool';
|
||||
pairingInfo.className = 'pairing-info paired';
|
||||
openOptionsBtn.hidden = true;
|
||||
} else {
|
||||
pairingInfo.textContent = 'Not paired. Add the one-time secret from APT.';
|
||||
pairingInfo.className = 'pairing-info unpaired';
|
||||
openOptionsBtn.hidden = false;
|
||||
}
|
||||
|
||||
const tabs = await chromeApi.tabs.query({ active: true, currentWindow: true });
|
||||
const tab = tabs && tabs[0];
|
||||
if (!tab || !isSupportedDeploymentUrl(tab.url)) {
|
||||
tabInfo.textContent = 'This tab is not an Alta deployment.';
|
||||
tabInfo.className = 'tab-info not-detected';
|
||||
updateActions();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(tab.url);
|
||||
detectedOrigin = url.origin;
|
||||
tabInfo.textContent = `Detected: ${url.hostname}`;
|
||||
tabInfo.className = 'tab-info detected';
|
||||
updateActions();
|
||||
}
|
||||
|
||||
return { copyToken, init, sendToApt };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
APT_URL,
|
||||
PAIRING_STORAGE_KEY,
|
||||
createPopupController,
|
||||
isSupportedDeploymentUrl
|
||||
};
|
||||
} else {
|
||||
createPopupController({
|
||||
chromeApi: chrome,
|
||||
documentApi: document,
|
||||
navigatorApi: navigator,
|
||||
fetchImpl: fetch,
|
||||
confirmCopy: (message) => window.confirm(message),
|
||||
cryptoApi: crypto
|
||||
}).init().catch(() => {
|
||||
const status = document.getElementById('statusMsg');
|
||||
status.textContent = 'Extension initialization failed. Open pairing settings and try again.';
|
||||
status.className = 'status-msg error';
|
||||
});
|
||||
}
|
||||
|
||||
+6
-5
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- GitHub Pages rebuild marker: 2026-05-22 -->
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Alta Proxy Tool</title>
|
||||
@@ -303,7 +303,7 @@
|
||||
<img src="icon.png" alt="Alta Proxy Tool">
|
||||
<span>Alta Proxy Tool</span>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<div class="hero">
|
||||
<img src="icon.png" alt="" class="hero-icon">
|
||||
<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>
|
||||
|
||||
<section class="setup">
|
||||
@@ -321,8 +321,9 @@
|
||||
<ol class="setup-steps">
|
||||
<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>Start APT and paste its one-time pairing secret into the extension settings</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>
|
||||
</ol>
|
||||
<p class="setup-note">Requires Windows 10+, Google Chrome, and an Avigilon Alta account.</p>
|
||||
@@ -333,7 +334,7 @@
|
||||
|
||||
<footer class="footer">
|
||||
<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>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Feature discovery begins only after Phase A passes independent security review a
|
||||
|
||||
## Non-negotiable guardrails
|
||||
|
||||
- GitPeji is the only source of truth. Do not fetch from, push to, publish on, or update from GitHub.
|
||||
- GitPeji is the only source of truth. Do not fetch from, push to, publish on, or update from alternate source hosts.
|
||||
- Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer.
|
||||
- Preserve current Tool Hub downloads and production visibility until Zac approves replacement.
|
||||
- Do not use real Alta cookies in automated tests; use conspicuous synthetic sentinels.
|
||||
@@ -73,7 +73,7 @@ git worktree add -b hardening/security-foundation /home/peji/worktrees/apt-secur
|
||||
git -C /home/peji/worktrees/apt-security-foundation status --short --branch
|
||||
```
|
||||
|
||||
**Expected:** clean branch based on exact GitPeji `origin/master`; no GitHub remote used.
|
||||
**Expected:** clean branch based on exact GitPeji `origin/master`; no alternate-host remote used.
|
||||
|
||||
**Commit:** `docs: record APT security hardening baseline`
|
||||
|
||||
@@ -387,7 +387,7 @@ npm run check
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yml`
|
||||
- Create: `.gitea/workflows/release.yml`
|
||||
- Remove or retire: `.github/workflows/deploy-pages.yml`
|
||||
- Remove or retire the legacy alternate-host Pages workflow.
|
||||
- Modify: `docs/index.html`
|
||||
- Create: `scripts/verify-kit.js`
|
||||
|
||||
@@ -395,7 +395,7 @@ npm run check
|
||||
|
||||
1. CI gate: `npm ci`, syntax, tests, audit policy, Windows unpacked build, kit verification, secret scan.
|
||||
2. Release gate: tag/version match, clean source, approved helper checksum, exact artifact hashes, SBOM, checksums, retained logs.
|
||||
3. Point docs/downloads to GitPeji or the approved Tool Hub route, never GitHub.
|
||||
3. Point docs/downloads to GitPeji or the approved Tool Hub route, never an alternate source host.
|
||||
4. Publish no artifact automatically until the first manual release rehearsal passes.
|
||||
5. Rehearse on a non-production candidate tag and verify fresh download/extraction.
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
- Implementation plan: `docs/plans/2026-08-19-apt-security-foundation.md`
|
||||
- Starting package version: `1.0.0`
|
||||
- Starting audit: 25 findings (1 critical, 22 high, 2 moderate); production-only audit 3 findings (2 high, 1 moderate)
|
||||
- Starting release state: GitPeji/GitHub split-brain, no application CI, unsafe unsigned updater, no automated tests
|
||||
- Starting release state: split release hosting, no application CI, unsafe unsigned updater, no automated tests
|
||||
|
||||
## Guardrails
|
||||
|
||||
- GitPeji only; GitHub remote removed.
|
||||
- GitPeji only; alternate-host remote removed.
|
||||
- Production Tool Hub and existing downloads remain unchanged.
|
||||
- Synthetic sentinel credentials only in tests.
|
||||
- Work remains on the isolated hardening branch until review gates pass.
|
||||
|
||||
+38
-40
@@ -3,45 +3,35 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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:;">
|
||||
<title>Alta Video Camera Proxy with API</title>
|
||||
<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</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Main Content Layout -->
|
||||
<div class="main-layout">
|
||||
<!-- Left Sidebar - Available Devices -->
|
||||
<aside class="devices-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Available Devices</h2>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-header"><h2>Available Devices</h2></div>
|
||||
<div id="deviceStatus" class="status-message"></div>
|
||||
|
||||
<!-- Device Search -->
|
||||
<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 class="device-list-container">
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<div class="content-header">
|
||||
<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">↻</span>
|
||||
<span class="update-text">Check for Updates</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Connection Section -->
|
||||
<section class="content-section">
|
||||
<h2>API Connection</h2>
|
||||
<div class="connection-status">
|
||||
@@ -59,21 +49,39 @@
|
||||
<div id="connectionStatus" class="status-message"></div>
|
||||
</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">
|
||||
<h2>Camera Proxy</h2>
|
||||
<div class="proxy-controls">
|
||||
<div class="input-row">
|
||||
<label for="cookieDeviceUUID">Device UUID:</label>
|
||||
<input type="text" id="cookieDeviceUUID" placeholder="(Auto-filled when you select a device from the list)" readonly>
|
||||
<label for="selectedDeviceId">Device UUID:</label>
|
||||
<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 class="input-row">
|
||||
<label for="altaUsername">Alta username / email:</label>
|
||||
<input type="text" id="altaUsername" maxlength="254" placeholder="name@example.com" autocomplete="username" spellcheck="false">
|
||||
</div>
|
||||
<p class="section-help">The proxy helper opens a Windows console and prompts for your password and 2FA. APT never sends those secrets on its command line.</p>
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="startCookieProxyBtn" class="btn-primary" disabled>Start Proxy</button>
|
||||
<button type="button" id="stopCookieProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
|
||||
<button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button>
|
||||
<button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -81,30 +89,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Modal -->
|
||||
<div id="updateModalOverlay" class="update-modal-overlay" style="display: none;">
|
||||
<div id="updateNotice" class="update-modal-overlay" style="display: none;">
|
||||
<div class="update-modal-card">
|
||||
<div class="update-modal-header">
|
||||
<h3>Update Available</h3>
|
||||
<button type="button" id="updateModalCloseBtn" class="update-modal-close">×</button>
|
||||
</div>
|
||||
<div class="update-modal-header"><h3>Update Available</h3></div>
|
||||
<div class="update-modal-body">
|
||||
<p id="updateModalMessage" 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>
|
||||
<p id="updateMessage" class="update-modal-message"></p>
|
||||
</div>
|
||||
<div class="update-modal-footer">
|
||||
<button type="button" id="updateInstallBtn" class="btn-primary">Install Update</button>
|
||||
<button type="button" id="updateLaterBtn" class="btn-outline">Later</button>
|
||||
<button type="button" id="openReleasesBtn" class="btn-primary">Open GitPeji Releases</button>
|
||||
<button type="button" id="dismissUpdateBtn" class="btn-outline">Later</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="renderer-controller.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,631 +1,179 @@
|
||||
const { app, BrowserWindow, ipcMain, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
'use strict';
|
||||
|
||||
let mainWindow;
|
||||
let activeProxyProcesses = new Map(); // Track active camera proxy processes
|
||||
let cookieServer = null;
|
||||
const COOKIE_SERVER_PORT = 18247;
|
||||
const COOKIE_SERVER_TOKEN = 'apt-local-bridge-token';
|
||||
const { app, BrowserWindow, ipcMain, safeStorage, shell } = require('electron');
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { createSessionStore } = require('./src/session-store');
|
||||
const { createAltaClient } = require('./src/alta-client');
|
||||
const { createProxyManager } = require('./src/proxy-launch');
|
||||
const { checkForUpdate } = require('./src/update-policy');
|
||||
const {
|
||||
AppRuntime,
|
||||
PAIRING_ENVELOPE_FILENAME,
|
||||
PairingController,
|
||||
createBridgeHandler,
|
||||
} = require('./src/electron-runtime');
|
||||
|
||||
const BRIDGE_HOST = '127.0.0.1';
|
||||
const BRIDGE_PORT = 18247;
|
||||
|
||||
let mainWindow = null;
|
||||
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() {
|
||||
if (app.isPackaged) {
|
||||
return path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'));
|
||||
}
|
||||
return __dirname;
|
||||
return app.isPackaged
|
||||
? path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'))
|
||||
: __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);
|
||||
function sendConnectionState() {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && runtime) {
|
||||
mainWindow.webContents.send('connection-state-changed', runtime.getConnectionState());
|
||||
}
|
||||
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() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
icon: path.join(__dirname, 'assets', 'icon.png'),
|
||||
title: 'Alta Video Camera Proxy',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
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
|
||||
if (process.argv.includes('--dev')) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
function isTrustedSender(event) {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !event) return false;
|
||||
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, username) => runtime.launchProxy(deviceId, username));
|
||||
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() {
|
||||
if (!pairingController.bridgeAuth) return;
|
||||
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(() => {
|
||||
const sessionStore = createSessionStore();
|
||||
const altaClient = createAltaClient({ sessionStore });
|
||||
const proxyManager = createProxyManager({ appDirectory: getAppDirectory() });
|
||||
const secureStorageAvailable = safeStorage && safeStorage.isEncryptionAvailable();
|
||||
pairingController = new PairingController({
|
||||
envelopePath: path.join(app.getPath('userData'), PAIRING_ENVELOPE_FILENAME),
|
||||
protect: secureStorageAvailable ? (value) => safeStorage.encryptString(value) : undefined,
|
||||
unprotect: secureStorageAvailable ? (value) => safeStorage.decryptString(value) : undefined,
|
||||
});
|
||||
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();
|
||||
startCookieServer();
|
||||
startBridgeServer();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (cookieServer) {
|
||||
cookieServer.close();
|
||||
app.on('before-quit', (event) => {
|
||||
if (runtime) {
|
||||
const state = runtime.disconnect();
|
||||
if (state.activeProxies.length > 0) {
|
||||
event.preventDefault();
|
||||
sendConnectionState();
|
||||
return;
|
||||
}
|
||||
runtime.sessionStore.dispose();
|
||||
}
|
||||
if (bridgeServer) bridgeServer.close();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
if (BrowserWindow.getAllWindows().length === 0 && runtime) 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' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Generated
+805
-2633
File diff suppressed because it is too large
Load Diff
+24
-13
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "alta-api-client",
|
||||
"version": "1.0.0",
|
||||
"description": "Electron app for connecting to Alta API",
|
||||
"version": "1.1.0",
|
||||
"description": "Secure Windows Electron client for the Alta Camera Proxy",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "node scripts/verify-kit.js && npm test",
|
||||
"audit:prod": "npm audit --omit=dev --audit-level=low",
|
||||
"build": "electron-builder --win --publish=never",
|
||||
"build-test": "electron-builder --win --dir",
|
||||
"build-test": "electron-builder --win --dir --publish=never",
|
||||
"build-kit": "powershell -ExecutionPolicy Bypass -File build-kit.ps1",
|
||||
"prebuild": "echo Checking build requirements..."
|
||||
},
|
||||
@@ -17,15 +20,25 @@
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"renderer.js",
|
||||
"renderer-controller.js",
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"src/**/*",
|
||||
"chrome-extension/**/*",
|
||||
"assets/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "portable",
|
||||
"icon": "assets/icon.png",
|
||||
"signAndEditExecutable": false
|
||||
"icon": "assets/icon.png"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "AltaCameraProxy-${version}-portable.exe"
|
||||
},
|
||||
"forceCodeSigning": false
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
@@ -33,15 +46,13 @@
|
||||
"api",
|
||||
"avigilon"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"author": "Peji",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^26.4.0",
|
||||
"electron-packager": "^17.1.2"
|
||||
"electron": "43.4.1",
|
||||
"electron-builder": "26.15.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"crypto-js": "^4.2.0"
|
||||
"axios": "1.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
+22
-34
@@ -1,38 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getDevices: (params) => ipcRenderer.invoke('api-get-devices', params),
|
||||
getDeviceSites: (params) => ipcRenderer.invoke('api-get-device-sites', params),
|
||||
getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params),
|
||||
function onConnectionStateChanged(callback) {
|
||||
if (typeof callback !== 'function') return () => {};
|
||||
const listener = (_event, state) => callback(state);
|
||||
ipcRenderer.on('connection-state-changed', listener);
|
||||
return () => ipcRenderer.removeListener('connection-state-changed', listener);
|
||||
}
|
||||
|
||||
// Camera proxy functionality
|
||||
launchCookieCameraProxy: (params) => ipcRenderer.invoke('camera-proxy-cookie-launch', params),
|
||||
stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }),
|
||||
|
||||
// Extension cookie bridge (push from main process)
|
||||
onExtensionCookie: (callback) => {
|
||||
ipcRenderer.on('extension-cookie-received', (event, data) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Extension cookie handler error:', error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Self-update functionality
|
||||
contextBridge.exposeInMainWorld('electronAPI', Object.freeze({
|
||||
getDevices: () => ipcRenderer.invoke('get-devices'),
|
||||
getDeviceSites: () => ipcRenderer.invoke('get-device-sites'),
|
||||
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
|
||||
launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username),
|
||||
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
|
||||
disconnect: () => ipcRenderer.invoke('disconnect'),
|
||||
getConnectionState: () => ipcRenderer.invoke('get-connection-state'),
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
downloadAndInstallUpdate: (params) => ipcRenderer.invoke('download-and-install-update', params),
|
||||
getCurrentVersion: () => ipcRenderer.invoke('get-current-version'),
|
||||
onUpdateDownloadProgress: (callback) => {
|
||||
ipcRenderer.on('update-download-progress', (event, data) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Update download progress handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
openFixedReleasesPage: () => ipcRenderer.invoke('open-fixed-releases-page'),
|
||||
rotatePairing: () => ipcRenderer.invoke('rotate-pairing'),
|
||||
revokePairing: () => ipcRenderer.invoke('revoke-pairing'),
|
||||
getPairingStatus: () => ipcRenderer.invoke('get-pairing-status'),
|
||||
onConnectionStateChanged,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeRendererController(globalScope) {
|
||||
function createRendererController({
|
||||
disconnect,
|
||||
renderConnectionState,
|
||||
clearDisconnectedState,
|
||||
showConnectionStatus,
|
||||
} = {}) {
|
||||
if (
|
||||
typeof disconnect !== 'function' ||
|
||||
typeof renderConnectionState !== 'function' ||
|
||||
typeof clearDisconnectedState !== 'function' ||
|
||||
typeof showConnectionStatus !== 'function'
|
||||
) {
|
||||
throw new TypeError('Renderer controller dependencies are invalid.');
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async disconnect() {
|
||||
const result = await disconnect();
|
||||
if (!result || result.success !== true) {
|
||||
const message = result && typeof result.message === 'string'
|
||||
? result.message
|
||||
: 'Failed to disconnect from Alta. The current connection remains active.';
|
||||
showConnectionStatus(message, 'error');
|
||||
return result;
|
||||
}
|
||||
|
||||
renderConnectionState(result);
|
||||
clearDisconnectedState();
|
||||
showConnectionStatus('Disconnected from Alta.', 'info');
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const api = Object.freeze({ createRendererController });
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (globalScope) globalScope.AptRendererController = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined));
|
||||
+277
-721
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const REQUIRED_EXTENSION_FILES = Object.freeze([
|
||||
'manifest.json', 'popup.html', 'popup.js', 'popup.css',
|
||||
'options.html', 'options.js', 'options.css',
|
||||
]);
|
||||
const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.md', '.ps1', '.yml', '.yaml']);
|
||||
const FORBIDDEN_PATTERNS = Object.freeze([
|
||||
['legacy bridge token', /apt-local-bridge-token/i],
|
||||
['legacy pairing header', /X-APT-Token/i],
|
||||
['legacy cookie proxy', /cookie-proxy-/i],
|
||||
['GitHub release API', /api\.github\.com/i],
|
||||
['executable updater', /download-and-install-update/i],
|
||||
['broad process termination', /taskkill/i],
|
||||
]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function normalizeRelative(name) {
|
||||
if (typeof name !== 'string' || name.length === 0 || name.includes('\0')) fail(`Unsafe empty or NUL path: ${name}`);
|
||||
const unix = name.replaceAll('\\', '/');
|
||||
if (unix.startsWith('/') || /^[A-Za-z]:\//.test(unix)) fail(`Unsafe absolute path: ${name}`);
|
||||
const normalized = path.posix.normalize(unix);
|
||||
if (normalized === '..' || normalized.startsWith('../')) fail(`Unsafe parent path: ${name}`);
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function assertTextPolicy(name, content) {
|
||||
for (const [label, pattern] of FORBIDDEN_PATTERNS) {
|
||||
if (pattern.test(content)) fail(`Forbidden ${label} string in ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectory(root, { ignoreTopLevel = [] } = {}) {
|
||||
const absoluteRoot = path.resolve(root);
|
||||
if (!fs.statSync(absoluteRoot).isDirectory()) fail(`Expected a directory: ${root}`);
|
||||
const files = new Map();
|
||||
const ignored = new Set(ignoreTopLevel);
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = normalizeRelative(path.relative(absoluteRoot, absolute));
|
||||
if (!relative.includes('/') && ignored.has(relative)) continue;
|
||||
if (entry.isSymbolicLink()) fail(`Unsafe symbolic link: ${relative}`);
|
||||
if (entry.isDirectory()) walk(absolute);
|
||||
else if (entry.isFile()) files.set(relative, () => fs.readFileSync(absolute));
|
||||
else fail(`Unsupported filesystem entry: ${relative}`);
|
||||
}
|
||||
};
|
||||
walk(absoluteRoot);
|
||||
return files;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(buffer) {
|
||||
const minimum = Math.max(0, buffer.length - 65_557);
|
||||
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
|
||||
}
|
||||
fail('Invalid ZIP: end-of-central-directory record not found');
|
||||
}
|
||||
|
||||
function listZip(zipPath) {
|
||||
const zip = fs.readFileSync(zipPath);
|
||||
const end = findEndOfCentralDirectory(zip);
|
||||
const entryCount = zip.readUInt16LE(end + 10);
|
||||
let offset = zip.readUInt32LE(end + 16);
|
||||
const files = new Map();
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (zip.readUInt32LE(offset) !== 0x02014b50) fail('Invalid ZIP central-directory entry');
|
||||
const flags = zip.readUInt16LE(offset + 8);
|
||||
const method = zip.readUInt16LE(offset + 10);
|
||||
const compressedSize = zip.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = zip.readUInt32LE(offset + 24);
|
||||
const nameLength = zip.readUInt16LE(offset + 28);
|
||||
const extraLength = zip.readUInt16LE(offset + 30);
|
||||
const commentLength = zip.readUInt16LE(offset + 32);
|
||||
const externalAttributes = zip.readUInt32LE(offset + 38);
|
||||
const localOffset = zip.readUInt32LE(offset + 42);
|
||||
const name = normalizeRelative(zip.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
if (name.endsWith('/')) continue;
|
||||
const unixMode = externalAttributes >>> 16;
|
||||
if ((unixMode & 0o170000) === 0o120000) fail(`Unsafe ZIP symbolic link: ${name}`);
|
||||
if ((flags & 1) !== 0) fail(`Encrypted ZIP entries are unsupported: ${name}`);
|
||||
if (files.has(name)) fail(`Duplicate ZIP entry: ${name}`);
|
||||
|
||||
files.set(name, () => {
|
||||
if (zip.readUInt32LE(localOffset) !== 0x04034b50) fail(`Invalid ZIP local entry: ${name}`);
|
||||
const localNameLength = zip.readUInt16LE(localOffset + 26);
|
||||
const localExtraLength = zip.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + localNameLength + localExtraLength;
|
||||
const compressed = zip.subarray(start, start + compressedSize);
|
||||
const content = method === 0 ? compressed : method === 8 ? zlib.inflateRawSync(compressed) : fail(`Unsupported ZIP method ${method}: ${name}`);
|
||||
if (content.length !== uncompressedSize) fail(`Invalid ZIP size for ${name}`);
|
||||
return content;
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function listAsar(asarPath) {
|
||||
const asar = require('@electron/asar');
|
||||
const files = new Map();
|
||||
for (const archiveName of asar.listPackage(asarPath)) {
|
||||
if (!archiveName.startsWith('/')) fail(`Unsafe ASAR path: ${archiveName}`);
|
||||
const name = normalizeRelative(archiveName.slice(1));
|
||||
let stat;
|
||||
try {
|
||||
stat = asar.statFile(asarPath, name);
|
||||
} catch (error) {
|
||||
fail(`Invalid ASAR entry ${name}: ${error.message}`);
|
||||
}
|
||||
if (!Number.isSafeInteger(stat.size)) continue;
|
||||
if (!name.startsWith('node_modules/')) files.set(name, () => asar.extractFile(asarPath, name));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function findFile(files, suffix) {
|
||||
const matches = [...files.keys()].filter((name) => name === suffix || name.endsWith(`/${suffix}`));
|
||||
if (matches.length !== 1) fail(`Expected exactly one ${suffix}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function verifyExtension(files, expectedVersion) {
|
||||
const manifestName = findFile(files, 'chrome-extension/manifest.json');
|
||||
const extensionRoot = manifestName.slice(0, -'manifest.json'.length);
|
||||
for (const required of REQUIRED_EXTENSION_FILES) {
|
||||
if (!files.has(`${extensionRoot}${required}`)) fail(`Missing expected extension file: ${required}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(files.get(manifestName)().toString('utf8'));
|
||||
} catch (error) {
|
||||
fail(`Invalid extension manifest: ${error.message}`);
|
||||
}
|
||||
if (manifest.version !== expectedVersion) {
|
||||
fail(`Extension version ${manifest.version} does not match application version ${expectedVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyTextFiles(files) {
|
||||
for (const [name, load] of files) {
|
||||
if (TEXT_EXTENSIONS.has(path.extname(name).toLowerCase())) assertTextPolicy(name, load().toString('utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
function verifySyntax(files) {
|
||||
const javascript = [...files.keys()].filter((name) => name.endsWith('.js'));
|
||||
javascript.push('scripts/verify-kit.js');
|
||||
for (const name of javascript) {
|
||||
const result = spawnSync(process.execPath, ['--check', path.join(ROOT, name)], { encoding: 'utf8' });
|
||||
if (result.status !== 0) fail(`JavaScript syntax check failed for ${name}: ${result.stderr.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySource() {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8'));
|
||||
const files = listDirectory(ROOT, { ignoreTopLevel: ['.git', 'dist', 'node_modules'] });
|
||||
verifyExtension(files, pkg.version);
|
||||
for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) {
|
||||
if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`);
|
||||
}
|
||||
const productionRoots = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/', 'chrome-extension/'];
|
||||
const productionFiles = new Map([...files].filter(([name]) =>
|
||||
productionRoots.some((root) => name === root || name.startsWith(root))
|
||||
));
|
||||
verifyTextFiles(productionFiles);
|
||||
verifySyntax(productionFiles);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function verifySupplied(target, expectedVersion, mode) {
|
||||
const absolute = path.resolve(target);
|
||||
if (!fs.existsSync(absolute)) fail(`Supplied ${mode} does not exist: ${target}`);
|
||||
const isDirectory = fs.statSync(absolute).isDirectory();
|
||||
const files = isDirectory ? listDirectory(absolute) : listZip(absolute);
|
||||
const hostNames = [...files.keys()];
|
||||
if (mode === 'build' && !hostNames.includes('chrome-extension/manifest.json')) {
|
||||
const asarName = hostNames.find((name) => /(?:^|\/)resources\/app\.asar$/i.test(name));
|
||||
if (!asarName || !isDirectory) fail('Missing packaged app.asar in supplied build');
|
||||
for (const [name, load] of listAsar(path.join(absolute, asarName))) files.set(name, load);
|
||||
}
|
||||
verifyExtension(files, expectedVersion);
|
||||
verifyTextFiles(files);
|
||||
if (!hostNames.some((name) => /(?:^|\/)Alta(?:CameraProxy| Camera Proxy)(?:-[^/]*)?\.exe$/i.test(name))) {
|
||||
fail(`Missing expected APT executable in supplied ${mode}`);
|
||||
}
|
||||
if (mode === 'kit' && !hostNames.some((name) => /(?:^|\/)aware-cam-proxy\.exe$/i.test(name))) {
|
||||
fail('Missing expected aware-cam-proxy.exe in supplied kit');
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const version = verifySource();
|
||||
if (argv.length > 0) {
|
||||
const buildMode = argv[0] === '--build';
|
||||
const target = buildMode ? argv[1] : argv[0];
|
||||
if (!target || argv.length !== (buildMode ? 2 : 1)) fail('Usage: node scripts/verify-kit.js [--build <directory>|<kit-directory-or-zip>]');
|
||||
verifySupplied(target, version, buildMode ? 'build' : 'kit');
|
||||
}
|
||||
process.stdout.write(`APT source${argv.length ? ' and supplied artifact' : ''} verified at version ${version}.\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`Verification failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeRelative, verifySource, verifySupplied };
|
||||
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy');
|
||||
const { formatAltaError } = require('./error-redaction');
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_REDIRECTS = 3;
|
||||
const ENDPOINTS = Object.freeze({
|
||||
getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }),
|
||||
getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }),
|
||||
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }),
|
||||
});
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
function altaError(code, message, extra = {}) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
Object.assign(error, extra);
|
||||
return error;
|
||||
}
|
||||
|
||||
async function axiosTransport(options) {
|
||||
// Lazy loading keeps pure/injected transport tests independent of installed
|
||||
// runtime dependencies while production still uses the pinned Axios package.
|
||||
const axios = require('axios');
|
||||
return axios.request({
|
||||
method: options.method,
|
||||
url: options.url,
|
||||
headers: options.headers,
|
||||
timeout: options.timeout,
|
||||
signal: options.signal,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: options.maxResponseBytes,
|
||||
maxBodyLength: 0,
|
||||
responseType: 'arraybuffer',
|
||||
validateStatus: () => true,
|
||||
transitional: { clarifyTimeoutError: true },
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers) {
|
||||
if (!headers || typeof headers !== 'object') return Object.freeze({});
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
normalized[String(key).toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function measuredData(data) {
|
||||
if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
|
||||
return { bytes: data.byteLength, raw: Buffer.from(data).toString('utf8') };
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
return { bytes: Buffer.byteLength(data), raw: data };
|
||||
}
|
||||
if (data === undefined) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE', 'Alta transport returned no response data');
|
||||
}
|
||||
try {
|
||||
const raw = JSON.stringify(data);
|
||||
if (raw === undefined) throw new Error('not serializable');
|
||||
return { bytes: Buffer.byteLength(raw), value: data };
|
||||
} catch {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response data is malformed');
|
||||
}
|
||||
}
|
||||
|
||||
function parseData(data, maxBytes) {
|
||||
const measured = measuredData(data);
|
||||
if (measured.bytes > maxBytes) {
|
||||
throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(measured, 'value')) return measured.value;
|
||||
try {
|
||||
return JSON.parse(measured.raw);
|
||||
} catch {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response was not valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function enforceResponseSize(data, headers, maxBytes) {
|
||||
const contentLength = headers['content-length'];
|
||||
if (contentLength !== undefined) {
|
||||
const parsedLength = Number(contentLength);
|
||||
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE', 'Alta response had an invalid content length');
|
||||
}
|
||||
if (parsedLength > maxBytes) {
|
||||
throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit');
|
||||
}
|
||||
}
|
||||
if (data !== undefined && measuredData(data).bytes > maxBytes) {
|
||||
throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit');
|
||||
}
|
||||
}
|
||||
|
||||
function safeTransportError(operation, error, cookie) {
|
||||
const formatted = formatAltaError(operation, error, { secrets: [cookie, `va=${cookie}`] });
|
||||
return altaError(formatted.code, formatted.message, {
|
||||
status: formatted.status,
|
||||
timeout: formatted.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
class AltaClient {
|
||||
constructor({
|
||||
sessionStore,
|
||||
transport = axiosTransport,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
||||
maxRedirects = DEFAULT_MAX_REDIRECTS,
|
||||
} = {}) {
|
||||
if (!sessionStore || typeof sessionStore.requireSession !== 'function') {
|
||||
throw new TypeError('AltaClient requires a session store');
|
||||
}
|
||||
if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) {
|
||||
throw new RangeError('AltaClient timeout must be between 1 and 10000ms');
|
||||
}
|
||||
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
||||
throw new RangeError('Invalid Alta response size limit');
|
||||
}
|
||||
if (!Number.isInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 5) {
|
||||
throw new RangeError('Invalid Alta redirect limit');
|
||||
}
|
||||
this.sessionStore = sessionStore;
|
||||
this.transport = transport;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.maxResponseBytes = maxResponseBytes;
|
||||
this.maxRedirects = maxRedirects;
|
||||
}
|
||||
|
||||
getDevices(...args) {
|
||||
return this.#invoke('getDevices', args);
|
||||
}
|
||||
|
||||
getDeviceSites(...args) {
|
||||
return this.#invoke('getDeviceSites', args);
|
||||
}
|
||||
|
||||
getAuthInfo(...args) {
|
||||
return this.#invoke('getAuthInfo', args);
|
||||
}
|
||||
|
||||
async #invoke(operation, args) {
|
||||
if (args.length !== 0) {
|
||||
throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters');
|
||||
}
|
||||
const endpoint = ENDPOINTS[operation];
|
||||
const session = this.sessionStore.requireSession();
|
||||
const origin = canonicalizeAltaOrigin(session.origin);
|
||||
const cookie = session.cookie;
|
||||
const deadline = Date.now() + this.timeoutMs;
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0);
|
||||
if (endpoint.shape === 'array' && !Array.isArray(data)) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array');
|
||||
}
|
||||
if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object');
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw safeTransportError(operation, error, cookie);
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async #request(operation, url, origin, cookie, deadline, controller, redirectCount) {
|
||||
assertSameAltaOrigin(url, origin);
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true });
|
||||
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true }));
|
||||
}, remaining);
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
Promise.resolve(this.transport(Object.freeze({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: Object.freeze({ Cookie: `va=${cookie}`, Accept: 'application/json' }),
|
||||
timeout: remaining,
|
||||
signal: controller.signal,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxResponseBytes: this.maxResponseBytes,
|
||||
}))),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response || typeof response !== 'object' || !Number.isInteger(response.status)) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE', 'Alta transport returned an invalid response');
|
||||
}
|
||||
const headers = normalizeHeaders(response.headers);
|
||||
// Bound every response, including redirects and errors, before acting on it.
|
||||
enforceResponseSize(response.data, headers, this.maxResponseBytes);
|
||||
|
||||
if (REDIRECT_STATUSES.has(response.status)) {
|
||||
if (typeof headers.location !== 'string' || headers.location.length === 0) {
|
||||
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect did not contain a safe location');
|
||||
}
|
||||
if (redirectCount >= this.maxRedirects) {
|
||||
throw altaError('TOO_MANY_ALTA_REDIRECTS', 'Alta response exceeded the redirect limit');
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = new URL(headers.location, url);
|
||||
assertSameAltaOrigin(target.href, origin);
|
||||
} catch {
|
||||
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin');
|
||||
}
|
||||
return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1);
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status > 299) {
|
||||
throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status });
|
||||
}
|
||||
|
||||
return parseData(response.data, this.maxResponseBytes);
|
||||
}
|
||||
}
|
||||
|
||||
function createAltaClient(options) {
|
||||
return new AltaClient(options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AltaClient,
|
||||
DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
ENDPOINTS,
|
||||
axiosTransport,
|
||||
createAltaClient,
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const DEFAULT_SECRET_BYTES = 32;
|
||||
const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
||||
const DEFAULT_BODY_DEADLINE_MS = 2_000;
|
||||
const DEFAULT_CHALLENGE_TTL_MS = 5_000;
|
||||
const DEFAULT_MAX_CHALLENGES = 64;
|
||||
const APT_EXTENSION_ID = 'onbkfpbggekakjddomjjnboippimlmch';
|
||||
const APT_EXTENSION_ORIGIN = `chrome-extension://${APT_EXTENSION_ID}`;
|
||||
const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const NONCE_PATTERN = SECRET_PATTERN;
|
||||
const PROOF_PATTERN = SECRET_PATTERN;
|
||||
const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/;
|
||||
const SERVER_PROOF_DOMAIN = 'apt-server-challenge-v1';
|
||||
const COOKIE_PROOF_DOMAIN = 'apt-cookie-request-v1';
|
||||
|
||||
class BridgeAuthError extends Error {
|
||||
constructor(code, message, statusCode = 400) {
|
||||
super(message);
|
||||
this.name = 'BridgeAuthError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(overrides = {}) {
|
||||
return {
|
||||
randomBytes: overrides.randomBytes || crypto.randomBytes,
|
||||
timingSafeEqual: overrides.timingSafeEqual || crypto.timingSafeEqual,
|
||||
createHmac: overrides.createHmac || crypto.createHmac,
|
||||
createHash: overrides.createHash || crypto.createHash,
|
||||
setTimeout: overrides.setTimeout || setTimeout,
|
||||
clearTimeout: overrides.clearTimeout || clearTimeout,
|
||||
now: overrides.now || Date.now
|
||||
};
|
||||
}
|
||||
|
||||
function generatePairingSecret(options = {}) {
|
||||
const deps = dependencies(options);
|
||||
const byteCount = options.byteCount || DEFAULT_SECRET_BYTES;
|
||||
if (!Number.isSafeInteger(byteCount) || byteCount < DEFAULT_SECRET_BYTES) {
|
||||
throw new BridgeAuthError('INVALID_SECRET_SIZE', 'Pairing secrets must contain at least 32 random bytes.');
|
||||
}
|
||||
return deps.randomBytes(byteCount).toString('base64url');
|
||||
}
|
||||
|
||||
function assertPairingSecret(secret) {
|
||||
if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret)) {
|
||||
throw new BridgeAuthError('INVALID_PAIRING_SECRET', 'The pairing secret has an invalid format.');
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalServerProof(clientNonce, serverNonce) {
|
||||
return JSON.stringify([SERVER_PROOF_DOMAIN, clientNonce, serverNonce]);
|
||||
}
|
||||
|
||||
function canonicalCookieProof({ clientNonce, serverNonce, deploymentUrl, cookieValue } = {}) {
|
||||
return JSON.stringify([COOKIE_PROOF_DOMAIN, clientNonce, serverNonce, deploymentUrl, cookieValue]);
|
||||
}
|
||||
|
||||
function computeHmac(secret, message, options = {}) {
|
||||
assertPairingSecret(secret);
|
||||
return dependencies(options).createHmac('sha256', secret).update(message, 'utf8').digest('base64url');
|
||||
}
|
||||
|
||||
function computeServerProof(secret, clientNonce, serverNonce, options = {}) {
|
||||
return computeHmac(secret, canonicalServerProof(clientNonce, serverNonce), options);
|
||||
}
|
||||
|
||||
function computeCookieProof(secret, request, options = {}) {
|
||||
return computeHmac(secret, canonicalCookieProof(request), options);
|
||||
}
|
||||
|
||||
function isValidEnvelope(envelope) {
|
||||
return Boolean(
|
||||
envelope &&
|
||||
envelope.version === 2 &&
|
||||
envelope.algorithm === 'electron-safe-storage' &&
|
||||
typeof envelope.ciphertext === 'string' &&
|
||||
/^[A-Za-z0-9+/]+={0,2}$/.test(envelope.ciphertext) &&
|
||||
envelope.ciphertext.length >= 16 && envelope.ciphertext.length <= 8192 &&
|
||||
typeof envelope.digest === 'string' &&
|
||||
PROOF_PATTERN.test(envelope.digest)
|
||||
);
|
||||
}
|
||||
|
||||
function assertExtensionOrigin(origin) {
|
||||
if (typeof origin !== 'string' || !EXTENSION_ORIGIN_PATTERN.test(origin) || origin !== APT_EXTENSION_ORIGIN) {
|
||||
throw new BridgeAuthError('INVALID_EXTENSION_ORIGIN', 'The bridge only accepts the committed Alta Proxy Tool extension origin.');
|
||||
}
|
||||
}
|
||||
|
||||
function safeEqualText(left, right, deps) {
|
||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
||||
const leftBytes = Buffer.from(left, 'utf8');
|
||||
const rightBytes = Buffer.from(right, 'utf8');
|
||||
return leftBytes.length === rightBytes.length && deps.timingSafeEqual(leftBytes, rightBytes);
|
||||
}
|
||||
|
||||
class BridgeAuth {
|
||||
constructor({
|
||||
expectedOrigin = APT_EXTENSION_ORIGIN,
|
||||
envelope = null,
|
||||
protect,
|
||||
unprotect,
|
||||
maxChallenges = DEFAULT_MAX_CHALLENGES,
|
||||
challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS,
|
||||
...injected
|
||||
} = {}) {
|
||||
assertExtensionOrigin(expectedOrigin);
|
||||
if (typeof protect !== 'function' || typeof unprotect !== 'function') {
|
||||
throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage is unavailable.', 503);
|
||||
}
|
||||
if (!Number.isSafeInteger(maxChallenges) || maxChallenges < 1 || maxChallenges > 1024) {
|
||||
throw new BridgeAuthError('INVALID_CHALLENGE_LIMIT', 'Challenge limit is invalid.');
|
||||
}
|
||||
if (!Number.isSafeInteger(challengeTtlMs) || challengeTtlMs < 1 || challengeTtlMs > 60_000) {
|
||||
throw new BridgeAuthError('INVALID_CHALLENGE_TTL', 'Challenge lifetime is invalid.');
|
||||
}
|
||||
if (envelope !== null && !isValidEnvelope(envelope)) {
|
||||
throw new BridgeAuthError('INVALID_SECRET_ENVELOPE', 'The persisted pairing envelope is invalid.');
|
||||
}
|
||||
|
||||
this.expectedOrigin = expectedOrigin;
|
||||
this._protect = protect;
|
||||
this._unprotect = unprotect;
|
||||
this._maxChallenges = maxChallenges;
|
||||
this._challengeTtlMs = challengeTtlMs;
|
||||
this._dependencies = dependencies(injected);
|
||||
this._challenges = new Map();
|
||||
this._secret = null;
|
||||
this._envelope = envelope ? Object.freeze({ ...envelope }) : null;
|
||||
|
||||
if (envelope) {
|
||||
try {
|
||||
const secret = unprotect(Buffer.from(envelope.ciphertext, 'base64'));
|
||||
assertPairingSecret(secret);
|
||||
const digest = this._dependencies.createHash('sha256').update(secret, 'utf8').digest('base64url');
|
||||
if (!safeEqualText(digest, envelope.digest, this._dependencies)) throw new Error('digest mismatch');
|
||||
this._secret = secret;
|
||||
} catch {
|
||||
throw new BridgeAuthError('INVALID_SECRET_ENVELOPE', 'The persisted pairing envelope could not be decrypted.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get envelope() {
|
||||
return this._envelope ? { ...this._envelope } : null;
|
||||
}
|
||||
|
||||
rotate() {
|
||||
const secret = generatePairingSecret(this._dependencies);
|
||||
let protectedBytes;
|
||||
try {
|
||||
protectedBytes = this._protect(secret);
|
||||
} catch {
|
||||
throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage is unavailable.', 503);
|
||||
}
|
||||
if (!Buffer.isBuffer(protectedBytes) || protectedBytes.length === 0 || protectedBytes.length > 6144) {
|
||||
throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage returned invalid ciphertext.', 503);
|
||||
}
|
||||
this._secret = secret;
|
||||
this._challenges.clear();
|
||||
this._envelope = Object.freeze({
|
||||
version: 2,
|
||||
algorithm: 'electron-safe-storage',
|
||||
ciphertext: protectedBytes.toString('base64'),
|
||||
digest: this._dependencies.createHash('sha256').update(secret, 'utf8').digest('base64url')
|
||||
});
|
||||
return { secret, envelope: this.envelope };
|
||||
}
|
||||
|
||||
revoke() {
|
||||
this._secret = null;
|
||||
this._envelope = null;
|
||||
this._challenges.clear();
|
||||
}
|
||||
|
||||
_purgeExpired(now) {
|
||||
for (const [nonce, challenge] of this._challenges) {
|
||||
if (challenge.expiresAt <= now) this._challenges.delete(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
issueChallenge(clientNonce) {
|
||||
if (!this._secret) throw new BridgeAuthError('PAIRING_UNAVAILABLE', 'Bridge pairing is unavailable.', 503);
|
||||
if (typeof clientNonce !== 'string' || !NONCE_PATTERN.test(clientNonce)) {
|
||||
throw new BridgeAuthError('INVALID_NONCE', 'Client nonce is invalid.');
|
||||
}
|
||||
const now = this._dependencies.now();
|
||||
this._purgeExpired(now);
|
||||
if (this._challenges.size >= this._maxChallenges) {
|
||||
throw new BridgeAuthError('CHALLENGE_CAPACITY', 'Too many bridge challenges are outstanding.', 429);
|
||||
}
|
||||
let serverNonce;
|
||||
do {
|
||||
serverNonce = this._dependencies.randomBytes(32).toString('base64url');
|
||||
} while (this._challenges.has(serverNonce));
|
||||
const expiresAt = now + this._challengeTtlMs;
|
||||
this._challenges.set(serverNonce, { clientNonce, expiresAt });
|
||||
return {
|
||||
serverNonce,
|
||||
serverProof: computeServerProof(this._secret, clientNonce, serverNonce, this._dependencies)
|
||||
};
|
||||
}
|
||||
|
||||
authenticateCookie(request = {}) {
|
||||
const { clientNonce, serverNonce, deploymentUrl, cookieValue, proof } = request;
|
||||
if (!this._secret || !NONCE_PATTERN.test(typeof clientNonce === 'string' ? clientNonce : '') ||
|
||||
!NONCE_PATTERN.test(typeof serverNonce === 'string' ? serverNonce : '') ||
|
||||
!PROOF_PATTERN.test(typeof proof === 'string' ? proof : '') ||
|
||||
typeof deploymentUrl !== 'string' || deploymentUrl.length < 1 || deploymentUrl.length > 2048 ||
|
||||
typeof cookieValue !== 'string' || cookieValue.length < 1 || cookieValue.length > 4096) {
|
||||
return false;
|
||||
}
|
||||
const challenge = this._challenges.get(serverNonce);
|
||||
if (!challenge) return false;
|
||||
this._challenges.delete(serverNonce);
|
||||
if (challenge.expiresAt <= this._dependencies.now() || challenge.clientNonce !== clientNonce) return false;
|
||||
const expected = computeCookieProof(this._secret, request, this._dependencies);
|
||||
return safeEqualText(proof, expected, this._dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonBody(stream, options = {}) {
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BODY_BYTES;
|
||||
const deadlineMs = options.deadlineMs ?? DEFAULT_BODY_DEADLINE_MS;
|
||||
const deps = dependencies(options);
|
||||
if (!stream || typeof stream.on !== 'function') return Promise.reject(new BridgeAuthError('INVALID_BODY_STREAM', 'A readable request body is required.'));
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return Promise.reject(new BridgeAuthError('INVALID_BODY_LIMIT', 'Body limit must be a positive integer.'));
|
||||
if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1) return Promise.reject(new BridgeAuthError('INVALID_BODY_DEADLINE', 'Body deadline must be a positive integer.'));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
deps.clearTimeout(timer);
|
||||
stream.removeListener('data', onData);
|
||||
stream.removeListener('end', onEnd);
|
||||
stream.removeListener('error', onError);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (typeof stream.pause === 'function') stream.pause();
|
||||
reject(error);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buffer.length;
|
||||
if (total > maxBytes) return fail(new BridgeAuthError('BODY_TOO_LARGE', 'Request body exceeds the configured limit.', 413));
|
||||
chunks.push(buffer);
|
||||
};
|
||||
const onError = () => fail(new BridgeAuthError('BODY_READ_ERROR', 'Could not read the request body.'));
|
||||
const onEnd = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
||||
catch { reject(new BridgeAuthError('MALFORMED_JSON', 'Request body must be valid JSON.')); return; }
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
reject(new BridgeAuthError('INVALID_JSON_BODY', 'Request body must be a JSON object.'));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
};
|
||||
const timer = deps.setTimeout(() => fail(new BridgeAuthError('BODY_DEADLINE_EXCEEDED', 'Request body deadline exceeded.', 408)), deadlineMs);
|
||||
stream.on('data', onData);
|
||||
stream.on('end', onEnd);
|
||||
stream.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
function createRequestLimiter({ maxConcurrent = 4 } = {}) {
|
||||
if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) throw new BridgeAuthError('INVALID_CONCURRENCY_LIMIT', 'Concurrency limit must be a positive integer.');
|
||||
let active = 0;
|
||||
return {
|
||||
get active() { return active; },
|
||||
async run(work) {
|
||||
if (typeof work !== 'function') throw new BridgeAuthError('INVALID_REQUEST_WORK', 'Request work must be a function.');
|
||||
if (active >= maxConcurrent) throw new BridgeAuthError('TOO_MANY_REQUESTS', 'Too many bridge requests are active.', 429);
|
||||
active += 1;
|
||||
try { return await work(); } finally { active -= 1; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APT_EXTENSION_ID,
|
||||
APT_EXTENSION_ORIGIN,
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
COOKIE_PROOF_DOMAIN,
|
||||
DEFAULT_BODY_DEADLINE_MS,
|
||||
DEFAULT_CHALLENGE_TTL_MS,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
DEFAULT_MAX_CHALLENGES,
|
||||
EXTENSION_ORIGIN_PATTERN,
|
||||
NONCE_PATTERN,
|
||||
SECRET_PATTERN,
|
||||
SERVER_PROOF_DOMAIN,
|
||||
canonicalCookieProof,
|
||||
canonicalServerProof,
|
||||
computeCookieProof,
|
||||
computeServerProof,
|
||||
createRequestLimiter,
|
||||
generatePairingSecret,
|
||||
isValidEnvelope,
|
||||
readJsonBody
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
'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, validateUsername } = 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', 'INVALID_USERNAME', 'SPAWN_FAILED',
|
||||
]);
|
||||
return error && allowed.has(error.code) && typeof error.message === 'string'
|
||||
? error.message
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function loadPairingEnvelope(envelopePath, { protect, unprotect } = {}) {
|
||||
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, protect, unprotect }).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, protect, unprotect } = {}) {
|
||||
if (typeof envelopePath !== 'string' || envelopePath.length === 0) {
|
||||
throw new TypeError('PairingController requires an envelope path');
|
||||
}
|
||||
this.envelopePath = envelopePath;
|
||||
this.unavailable = false;
|
||||
if (bridgeAuth) {
|
||||
this.bridgeAuth = bridgeAuth;
|
||||
} else if (typeof protect !== 'function' || typeof unprotect !== 'function') {
|
||||
this.bridgeAuth = null;
|
||||
this.unavailable = true;
|
||||
} else {
|
||||
let envelope = null;
|
||||
try {
|
||||
envelope = loadPairingEnvelope(envelopePath, { protect, unprotect });
|
||||
} catch {
|
||||
try { fs.unlinkSync(envelopePath); } catch {}
|
||||
}
|
||||
this.bridgeAuth = new BridgeAuth({ envelope, protect, unprotect });
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.unavailable) return { paired: false, unavailable: true };
|
||||
if (this.bridgeAuth.envelope) return { paired: true };
|
||||
return this.rotate();
|
||||
}
|
||||
|
||||
rotate() {
|
||||
if (this.unavailable || !this.bridgeAuth) {
|
||||
throw new BridgeAuthError('SECURE_STORAGE_UNAVAILABLE', 'Secure pairing storage is unavailable.', 503);
|
||||
}
|
||||
const { secret, envelope } = this.bridgeAuth.rotate();
|
||||
try {
|
||||
atomicWritePairingEnvelope(this.envelopePath, envelope);
|
||||
} catch (error) {
|
||||
this.bridgeAuth.revoke();
|
||||
throw error;
|
||||
}
|
||||
return { paired: true, secret };
|
||||
}
|
||||
|
||||
revoke() {
|
||||
if (this.bridgeAuth) this.bridgeAuth.revoke();
|
||||
try {
|
||||
fs.unlinkSync(this.envelopePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return this.unavailable ? { paired: false, unavailable: true } : { paired: false };
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
if (this.unavailable) return { paired: false, unavailable: true };
|
||||
return { paired: Boolean(this.bridgeAuth && 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.issueChallenge !== 'function' || typeof bridgeAuth.authenticateCookie !== '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');
|
||||
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 !== '/challenge' && request.url !== '/cookie')) {
|
||||
writeJson(response, 404, { success: false, message: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload;
|
||||
await limiter.run(async () => {
|
||||
const data = await readJsonBody(request, { maxBytes: maxBodyBytes, deadlineMs });
|
||||
if (request.url === '/challenge') {
|
||||
payload = { success: true, ...bridgeAuth.issueChallenge(data.clientNonce) };
|
||||
return;
|
||||
}
|
||||
if (!bridgeAuth.authenticateCookie(data)) {
|
||||
throw new BridgeAuthError('FORBIDDEN', 'Cookie proof was rejected.', 403);
|
||||
}
|
||||
const state = sessionStore.establish(data.deploymentUrl, data.cookieValue);
|
||||
onConnectionStateChanged({ connected: state.connected, origin: state.origin });
|
||||
payload = { success: true, message: 'Session received' };
|
||||
});
|
||||
writeJson(response, 200, payload);
|
||||
} catch (error) {
|
||||
const statusCode = error instanceof BridgeAuthError
|
||||
? error.statusCode
|
||||
: error && error.code === 'INVALID_SESSION_COOKIE'
|
||||
? 400
|
||||
: 400;
|
||||
const message = statusCode === 429 ? 'Too many requests'
|
||||
: statusCode === 403 ? 'Forbidden'
|
||||
: statusCode === 503 ? 'Pairing unavailable'
|
||||
: 'Invalid request';
|
||||
writeJson(response, statusCode, { success: false, message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
_reconcileProxies() {
|
||||
const tracked = this.proxyManager && typeof this.proxyManager.listTrackedProxies === 'function'
|
||||
? this.proxyManager.listTrackedProxies()
|
||||
: [];
|
||||
this.proxyByDevice = new Map();
|
||||
for (const proxy of tracked) {
|
||||
try {
|
||||
const deviceId = validateDeviceId(proxy.deviceId);
|
||||
if (Number.isSafeInteger(proxy.processId) && proxy.processId > 0) {
|
||||
this.proxyByDevice.set(deviceId, proxy.processId);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
|
||||
}
|
||||
|
||||
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, username) {
|
||||
try {
|
||||
const validatedId = validateDeviceId(deviceId);
|
||||
const validatedUsername = validateUsername(username);
|
||||
this._reconcileProxies();
|
||||
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,
|
||||
username: validatedUsername,
|
||||
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) {
|
||||
this._reconcileProxies();
|
||||
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() {
|
||||
const tracked = this._reconcileProxies();
|
||||
for (const proxy of tracked) {
|
||||
try { this.proxyManager.stopProxy(proxy.processId); } catch {}
|
||||
}
|
||||
const remaining = this._reconcileProxies();
|
||||
if (remaining.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not stop every active proxy. The Alta session remains connected.',
|
||||
...this.getConnectionState(),
|
||||
};
|
||||
}
|
||||
this.sessionStore.clear();
|
||||
this.allowedDeviceIds.clear();
|
||||
return { success: true, ...this.getConnectionState() };
|
||||
}
|
||||
|
||||
getConnectionState() {
|
||||
this._reconcileProxies();
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
'use strict';
|
||||
|
||||
const MAX_OPERATION_LENGTH = 64;
|
||||
const MAX_CODE_LENGTH = 64;
|
||||
const MAX_MESSAGE_LENGTH = 240;
|
||||
const REDACTED = '[REDACTED]';
|
||||
|
||||
function safeRead(object, key) {
|
||||
try {
|
||||
return object && typeof object === 'object' ? object[key] : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function redactKnownPatterns(message) {
|
||||
return message
|
||||
.replace(/\bauthorization\s*:\s*bearer\s+[^\s,;]+/gi, REDACTED)
|
||||
.replace(/\bbearer\s+[^\s,;]+/gi, `Bearer ${REDACTED}`)
|
||||
.replace(/\bcookie\s*:\s*[^\r\n]*/gi, REDACTED)
|
||||
.replace(/\b(va\s*=\s*)[^\s;,]*/gi, `$1${REDACTED}`);
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(value, options = {}) {
|
||||
let message = typeof value === 'string' ? value : 'Alta request failed';
|
||||
const secrets = Array.isArray(options.secrets) ? options.secrets : [];
|
||||
|
||||
for (const secret of secrets) {
|
||||
if (typeof secret === 'string' && secret.length > 0) {
|
||||
message = message.split(secret).join(REDACTED);
|
||||
}
|
||||
}
|
||||
|
||||
message = redactKnownPatterns(message)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!message) message = 'Alta request failed';
|
||||
return message.slice(0, MAX_MESSAGE_LENGTH);
|
||||
}
|
||||
|
||||
function includesSecret(value, secrets) {
|
||||
return secrets.some((secret) => typeof secret === 'string' && secret.length > 0 && value.includes(secret));
|
||||
}
|
||||
|
||||
function safeIdentifier(value, fallback, maxLength, secrets) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]+$/.test(value)) return fallback;
|
||||
if (includesSecret(value, secrets)) return fallback;
|
||||
return value.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeStatus(error) {
|
||||
const response = safeRead(error, 'response');
|
||||
const candidate = safeRead(response, 'status') ?? safeRead(error, 'status');
|
||||
return Number.isInteger(candidate) && candidate >= 100 && candidate <= 599 ? candidate : null;
|
||||
}
|
||||
|
||||
function formatAltaError(operation, error, options = {}) {
|
||||
const secrets = Array.isArray(options.secrets) ? options.secrets : [];
|
||||
const rawMessage = safeRead(error, 'message');
|
||||
const rawCode = safeRead(error, 'code');
|
||||
const timeoutValue = safeRead(error, 'timeout');
|
||||
const timeout = timeoutValue === true || rawCode === 'ECONNABORTED' || rawCode === 'ETIMEDOUT' || rawCode === 'ALTA_TIMEOUT';
|
||||
|
||||
// Deliberately construct a fresh allowlisted object. Never copy or spread the
|
||||
// source error: Axios config/request/headers/data can carry the bearer cookie.
|
||||
return Object.freeze({
|
||||
operation: safeIdentifier(operation, 'altaRequest', MAX_OPERATION_LENGTH, secrets),
|
||||
code: safeIdentifier(rawCode, 'ALTA_ERROR', MAX_CODE_LENGTH, secrets),
|
||||
status: safeStatus(error),
|
||||
timeout,
|
||||
message: sanitizeErrorMessage(rawMessage, options),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CODE_LENGTH,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
MAX_OPERATION_LENGTH,
|
||||
formatAltaError,
|
||||
formatError: formatAltaError,
|
||||
sanitizeErrorMessage,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
'use strict';
|
||||
|
||||
const nodeFs = require('node:fs');
|
||||
const nodePath = require('node:path');
|
||||
const { spawn: nodeSpawn } = require('node:child_process');
|
||||
|
||||
const HELPER_FILENAME = 'aware-cam-proxy.exe';
|
||||
const MAX_USERNAME_LENGTH = 254;
|
||||
const MAX_HOST_LENGTH = 253;
|
||||
const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const ALTA_SUFFIXES = ['.avasecurity.com', '.avigilon.com'];
|
||||
|
||||
class ProxyLaunchError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'ProxyLaunchError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function validateDeploymentHost(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_HOST_LENGTH) {
|
||||
throw new ProxyLaunchError('Deployment host is invalid.', 'INVALID_DEPLOYMENT_HOST');
|
||||
}
|
||||
|
||||
const host = value.toLowerCase();
|
||||
const labels = host.split('.');
|
||||
const hasAltaSuffix = ALTA_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
||||
if (!hasAltaSuffix || labels.some((label) => !DNS_LABEL_PATTERN.test(label))) {
|
||||
throw new ProxyLaunchError('Deployment host is invalid.', 'INVALID_DEPLOYMENT_HOST');
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
function validateDeviceId(value) {
|
||||
if (typeof value !== 'string' || value.length !== 36 || !DEVICE_ID_PATTERN.test(value)) {
|
||||
throw new ProxyLaunchError('Device identifier is invalid.', 'INVALID_DEVICE_ID');
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function validateUsername(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_USERNAME_LENGTH || value.trim().length === 0) {
|
||||
throw new ProxyLaunchError('Alta username is invalid.', 'INVALID_USERNAME');
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) {
|
||||
throw new ProxyLaunchError('Alta username must not contain control characters.', 'INVALID_USERNAME');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeProcessError(error) {
|
||||
const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error';
|
||||
return source.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512);
|
||||
}
|
||||
|
||||
function safeMetadata(entry, status = entry.status) {
|
||||
return {
|
||||
processId: entry.processId,
|
||||
deviceId: entry.deviceId,
|
||||
startedAt: entry.startedAt,
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
function createProxyManager({
|
||||
appDirectory,
|
||||
fs = nodeFs,
|
||||
spawn = nodeSpawn,
|
||||
platform = process.platform,
|
||||
now = Date.now
|
||||
} = {}) {
|
||||
if (platform !== 'win32') {
|
||||
throw new ProxyLaunchError('Proxy helper is supported only on the Windows platform.', 'UNSUPPORTED_PLATFORM');
|
||||
}
|
||||
if (typeof appDirectory !== 'string' || !nodePath.win32.isAbsolute(appDirectory)) {
|
||||
throw new ProxyLaunchError('Approved application directory must be an absolute path.', 'INVALID_APP_DIRECTORY');
|
||||
}
|
||||
if (typeof fs.existsSync !== 'function' || typeof spawn !== 'function' || typeof now !== 'function') {
|
||||
throw new TypeError('Invalid proxy manager dependency.');
|
||||
}
|
||||
|
||||
const helperPath = nodePath.win32.join(appDirectory, HELPER_FILENAME);
|
||||
const trackedChildren = new Map();
|
||||
|
||||
function removeIfOwned(processId, child) {
|
||||
const current = trackedChildren.get(processId);
|
||||
if (current && current.child === child) trackedChildren.delete(processId);
|
||||
}
|
||||
|
||||
function launchProxy(request) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new ProxyLaunchError('Proxy launch request is invalid.', 'INVALID_REQUEST');
|
||||
}
|
||||
|
||||
const deploymentHost = validateDeploymentHost(request.deploymentHost);
|
||||
const deviceId = validateDeviceId(request.deviceId);
|
||||
const username = validateUsername(request.username);
|
||||
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND');
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawn(
|
||||
helperPath,
|
||||
['-a', deploymentHost, '-u', username, '-d', deviceId],
|
||||
{
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
windowsHide: false
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ProxyLaunchError(
|
||||
`Failed to launch proxy helper: ${safeProcessError(error)}`,
|
||||
'SPAWN_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
if (!child || !Number.isSafeInteger(child.pid) || child.pid <= 0 || typeof child.kill !== 'function') {
|
||||
throw new ProxyLaunchError('Proxy helper did not return a valid child process.', 'INVALID_CHILD_PROCESS');
|
||||
}
|
||||
if (trackedChildren.has(child.pid)) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// The new child is deliberately not tracked when its PID collides.
|
||||
}
|
||||
throw new ProxyLaunchError('Proxy helper returned a process identifier already in use.', 'DUPLICATE_PROCESS_ID');
|
||||
}
|
||||
|
||||
const entry = {
|
||||
child,
|
||||
processId: child.pid,
|
||||
deviceId,
|
||||
startedAt: now(),
|
||||
status: 'running'
|
||||
};
|
||||
trackedChildren.set(entry.processId, entry);
|
||||
|
||||
if (typeof child.once === 'function') {
|
||||
child.once('exit', () => removeIfOwned(entry.processId, child));
|
||||
child.once('error', () => removeIfOwned(entry.processId, child));
|
||||
}
|
||||
|
||||
return { success: true, ...safeMetadata(entry) };
|
||||
}
|
||||
|
||||
function stopProxy(processId) {
|
||||
if (!Number.isSafeInteger(processId) || processId <= 0) {
|
||||
throw new ProxyLaunchError('Process identifier is invalid.', 'INVALID_PROCESS_ID');
|
||||
}
|
||||
|
||||
const entry = trackedChildren.get(processId);
|
||||
if (!entry) return { success: false, processId, status: 'not-tracked' };
|
||||
|
||||
try {
|
||||
const requested = entry.child.kill('SIGTERM');
|
||||
if (!requested) {
|
||||
removeIfOwned(processId, entry.child);
|
||||
return {
|
||||
success: true,
|
||||
processId,
|
||||
deviceId: entry.deviceId,
|
||||
status: 'already-exited'
|
||||
};
|
||||
}
|
||||
|
||||
removeIfOwned(processId, entry.child);
|
||||
return {
|
||||
success: true,
|
||||
processId,
|
||||
deviceId: entry.deviceId,
|
||||
status: 'stop-requested'
|
||||
};
|
||||
} catch (error) {
|
||||
const permissionDenied = error && (error.code === 'EPERM' || error.code === 'EACCES');
|
||||
return {
|
||||
success: false,
|
||||
processId,
|
||||
deviceId: entry.deviceId,
|
||||
status: permissionDenied ? 'permission-denied' : 'stop-failed',
|
||||
message: permissionDenied
|
||||
? 'Unable to stop the tracked proxy process: permission denied.'
|
||||
: 'Unable to stop the tracked proxy process.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function listTrackedProxies() {
|
||||
return Array.from(trackedChildren.values(), (entry) => safeMetadata(entry));
|
||||
}
|
||||
|
||||
return Object.freeze({ launchProxy, stopProxy, listTrackedProxies });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELPER_FILENAME,
|
||||
MAX_USERNAME_LENGTH,
|
||||
ProxyLaunchError,
|
||||
createProxyManager,
|
||||
validateDeploymentHost,
|
||||
validateDeviceId,
|
||||
validateUsername
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const { canonicalizeAltaOrigin } = require('./url-policy');
|
||||
|
||||
const MAX_COOKIE_LENGTH = 4096;
|
||||
|
||||
function sessionError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function validateCookie(cookie) {
|
||||
if (
|
||||
typeof cookie !== 'string' ||
|
||||
cookie.length === 0 ||
|
||||
cookie.length > MAX_COOKIE_LENGTH ||
|
||||
/[;\r\n\0]/.test(cookie)
|
||||
) {
|
||||
throw sessionError('INVALID_SESSION_COOKIE', 'Invalid Alta session cookie');
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
class SessionStore {
|
||||
#origin = null;
|
||||
#cookie = null;
|
||||
#disposed = false;
|
||||
|
||||
establish(deploymentOrigin, cookie) {
|
||||
if (this.#disposed) {
|
||||
throw sessionError('SESSION_STORE_DISPOSED', 'Alta session store is disposed');
|
||||
}
|
||||
|
||||
// Validate the complete replacement before dropping the current session.
|
||||
const nextOrigin = canonicalizeAltaOrigin(deploymentOrigin);
|
||||
const nextCookie = validateCookie(cookie);
|
||||
this.#origin = nextOrigin;
|
||||
this.#cookie = nextCookie;
|
||||
return this.describe();
|
||||
}
|
||||
|
||||
setSession(deploymentOrigin, cookie) {
|
||||
return this.establish(deploymentOrigin, cookie);
|
||||
}
|
||||
|
||||
requireSession() {
|
||||
if (this.#disposed || this.#origin === null || this.#cookie === null) {
|
||||
throw sessionError('NO_ALTA_SESSION', 'No active Alta session');
|
||||
}
|
||||
return Object.freeze({ origin: this.#origin, cookie: this.#cookie });
|
||||
}
|
||||
|
||||
describe() {
|
||||
return Object.freeze({
|
||||
connected: !this.#disposed && this.#origin !== null && this.#cookie !== null,
|
||||
origin: !this.#disposed ? this.#origin : null,
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#cookie = null;
|
||||
this.#origin = null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.clear();
|
||||
this.#disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionStore() {
|
||||
return new SessionStore();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_COOKIE_LENGTH,
|
||||
SessionStore,
|
||||
createSessionStore,
|
||||
validateCookie,
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
'use strict';
|
||||
|
||||
const https = require('node:https');
|
||||
|
||||
const LATEST_RELEASE_URL = 'https://git.pejicorp.com/api/v1/repos/peji/Alta-Proxy-Tool/releases/latest';
|
||||
const RELEASES_PAGE_URL = 'https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases';
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const MAX_RELEASE_NAME_LENGTH = 200;
|
||||
|
||||
// Strict SemVer 2.0.0 without loose forms such as omitted fields.
|
||||
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
|
||||
class UpdatePolicyError extends Error {
|
||||
constructor(code, message, options) {
|
||||
super(message, options);
|
||||
this.name = 'UpdatePolicyError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function policyError(code, message, cause) {
|
||||
return new UpdatePolicyError(code, message, cause ? { cause } : undefined);
|
||||
}
|
||||
|
||||
function parseSemver(version, errorCode = 'INVALID_VERSION') {
|
||||
if (typeof version !== 'string') {
|
||||
throw policyError(errorCode, 'Version must be a strict semantic version string');
|
||||
}
|
||||
|
||||
const match = SEMVER_PATTERN.exec(version);
|
||||
if (!match) {
|
||||
throw policyError(errorCode, 'Version must use strict SemVer 2.0.0 syntax');
|
||||
}
|
||||
|
||||
return {
|
||||
major: BigInt(match[1]),
|
||||
minor: BigInt(match[2]),
|
||||
patch: BigInt(match[3]),
|
||||
prerelease: match[4] === undefined ? null : match[4].split('.'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReleaseTag(tag) {
|
||||
const version = tag.startsWith('v') ? tag.slice(1) : tag;
|
||||
parseSemver(version, 'INVALID_RELEASE_VERSION');
|
||||
return version;
|
||||
}
|
||||
|
||||
function compareIdentifier(left, right) {
|
||||
const leftNumeric = /^\d+$/.test(left);
|
||||
const rightNumeric = /^\d+$/.test(right);
|
||||
|
||||
if (leftNumeric && rightNumeric) {
|
||||
const leftNumber = BigInt(left);
|
||||
const rightNumber = BigInt(right);
|
||||
return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0;
|
||||
}
|
||||
if (leftNumeric !== rightNumeric) {
|
||||
return leftNumeric ? -1 : 1;
|
||||
}
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function compareSemver(leftVersion, rightVersion) {
|
||||
const left = parseSemver(leftVersion);
|
||||
const right = parseSemver(rightVersion);
|
||||
|
||||
for (const key of ['major', 'minor', 'patch']) {
|
||||
if (left[key] < right[key]) return -1;
|
||||
if (left[key] > right[key]) return 1;
|
||||
}
|
||||
|
||||
if (left.prerelease === null && right.prerelease === null) return 0;
|
||||
if (left.prerelease === null) return 1;
|
||||
if (right.prerelease === null) return -1;
|
||||
|
||||
const count = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (left.prerelease[index] === undefined) return -1;
|
||||
if (right.prerelease[index] === undefined) return 1;
|
||||
const comparison = compareIdentifier(left.prerelease[index], right.prerelease[index]);
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sanitizeText(value) {
|
||||
return value
|
||||
.trim()
|
||||
.slice(0, MAX_RELEASE_NAME_LENGTH)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function validateRuntimeValue(value, field) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
throw policyError('INVALID_RUNTIME_METADATA', `Invalid ${field} metadata`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRelease(body) {
|
||||
let release;
|
||||
try {
|
||||
release = JSON.parse(body.toString('utf8'));
|
||||
} catch (error) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release response was not valid JSON', error);
|
||||
}
|
||||
|
||||
if (!release || typeof release !== 'object' || Array.isArray(release)) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release response must be an object');
|
||||
}
|
||||
if (typeof release.tag_name !== 'string' || release.tag_name.length > 128) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release tag_name must be a bounded string');
|
||||
}
|
||||
if (release.name !== undefined && release.name !== null && typeof release.name !== 'string') {
|
||||
throw policyError('INVALID_RESPONSE', 'Release name must be a string when provided');
|
||||
}
|
||||
if (typeof release.name === 'string' && release.name.length > 1000) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release name exceeds the schema limit');
|
||||
}
|
||||
if (release.published_at !== undefined && release.published_at !== null && typeof release.published_at !== 'string') {
|
||||
throw policyError('INVALID_RESPONSE', 'Release published_at must be a string when provided');
|
||||
}
|
||||
|
||||
let publishedAt;
|
||||
if (typeof release.published_at === 'string') {
|
||||
if (release.published_at.length > 64) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release published_at exceeds the schema limit');
|
||||
}
|
||||
const timestamp = new Date(release.published_at);
|
||||
if (Number.isNaN(timestamp.getTime())) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release published_at must be a valid timestamp');
|
||||
}
|
||||
publishedAt = timestamp.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
latestVersion: release.tag_name,
|
||||
releaseName: sanitizeText(release.name || release.tag_name),
|
||||
publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultRequest({
|
||||
url,
|
||||
timeoutMs,
|
||||
maxBodyBytes,
|
||||
httpsGet = https.get,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
}) {
|
||||
if (url !== LATEST_RELEASE_URL) {
|
||||
return Promise.reject(policyError('UNTRUSTED_REQUEST_URL', 'Update checks are restricted to GitPeji'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let responseStream = null;
|
||||
let deadlineTimer = null;
|
||||
const clearDeadline = () => {
|
||||
if (deadlineTimer !== null) {
|
||||
clearTimer(deadlineTimer);
|
||||
deadlineTimer = null;
|
||||
}
|
||||
};
|
||||
const finishReject = (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearDeadline();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const request = httpsGet(url, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'Alta-Proxy-Tool-update-check',
|
||||
},
|
||||
agent: false,
|
||||
}, (response) => {
|
||||
responseStream = response;
|
||||
const chunks = [];
|
||||
let receivedBytes = 0;
|
||||
const contentLength = Number(response.headers['content-length']);
|
||||
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
||||
response.destroy();
|
||||
finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit'));
|
||||
return;
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
receivedBytes += chunk.length;
|
||||
if (receivedBytes > maxBodyBytes) {
|
||||
response.destroy();
|
||||
finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearDeadline();
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
url,
|
||||
});
|
||||
});
|
||||
response.on('error', finishReject);
|
||||
});
|
||||
|
||||
deadlineTimer = setTimer(() => {
|
||||
const timeoutError = policyError('REQUEST_TIMEOUT', 'Release check timed out');
|
||||
if (responseStream && typeof responseStream.destroy === 'function') responseStream.destroy();
|
||||
request.destroy(timeoutError);
|
||||
finishReject(timeoutError);
|
||||
}, timeoutMs);
|
||||
request.on('error', (error) => {
|
||||
if (error instanceof UpdatePolicyError) {
|
||||
finishReject(error);
|
||||
} else {
|
||||
finishReject(policyError('REQUEST_FAILED', 'Release check failed', error));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdate({
|
||||
currentVersion,
|
||||
request = defaultRequest,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
} = {}) {
|
||||
parseSemver(currentVersion, 'INVALID_CURRENT_VERSION');
|
||||
if (typeof request !== 'function') {
|
||||
throw policyError('INVALID_REQUEST_ADAPTER', 'Request adapter must be a function');
|
||||
}
|
||||
|
||||
const safePlatform = validateRuntimeValue(platform, 'platform');
|
||||
const safeArch = validateRuntimeValue(arch, 'architecture');
|
||||
const baseMetadata = {
|
||||
currentVersion,
|
||||
releasesPageUrl: RELEASES_PAGE_URL,
|
||||
platform: safePlatform,
|
||||
arch: safeArch,
|
||||
};
|
||||
|
||||
const response = await request({
|
||||
url: LATEST_RELEASE_URL,
|
||||
timeoutMs: REQUEST_TIMEOUT_MS,
|
||||
maxBodyBytes: MAX_BODY_BYTES,
|
||||
redirects: 'error',
|
||||
});
|
||||
|
||||
if (!response || typeof response !== 'object') {
|
||||
throw policyError('INVALID_RESPONSE', 'Request adapter returned an invalid response');
|
||||
}
|
||||
if (response.url !== LATEST_RELEASE_URL) {
|
||||
throw policyError('UNTRUSTED_RESPONSE_URL', 'Release response did not come from the exact GitPeji endpoint');
|
||||
}
|
||||
|
||||
const statusCode = Number(response.statusCode);
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
throw policyError('REDIRECT_REJECTED', 'Release endpoint redirects are not allowed');
|
||||
}
|
||||
if (statusCode === 404) {
|
||||
return { status: 'no-release', ...baseMetadata };
|
||||
}
|
||||
if (statusCode !== 200) {
|
||||
throw policyError('HTTP_ERROR', `Release endpoint returned HTTP ${statusCode}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers && response.headers['content-type'];
|
||||
if (typeof contentType !== 'string' || !/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
||||
throw policyError('INVALID_CONTENT_TYPE', 'Release endpoint did not return JSON');
|
||||
}
|
||||
|
||||
const body = Buffer.isBuffer(response.body)
|
||||
? response.body
|
||||
: typeof response.body === 'string'
|
||||
? Buffer.from(response.body)
|
||||
: null;
|
||||
if (!body) {
|
||||
throw policyError('INVALID_RESPONSE', 'Release response body must be bytes or text');
|
||||
}
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
throw policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit');
|
||||
}
|
||||
|
||||
const release = parseRelease(body);
|
||||
const latestVersion = normalizeReleaseTag(release.latestVersion);
|
||||
|
||||
const metadata = {
|
||||
status: compareSemver(latestVersion, currentVersion) > 0
|
||||
? 'update-available'
|
||||
: 'up-to-date',
|
||||
...baseMetadata,
|
||||
latestVersion,
|
||||
releaseName: release.releaseName,
|
||||
};
|
||||
if (release.publishedAt !== undefined) {
|
||||
metadata.publishedAt = release.publishedAt;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LATEST_RELEASE_URL,
|
||||
RELEASES_PAGE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
MAX_BODY_BYTES,
|
||||
UpdatePolicyError,
|
||||
checkForUpdate,
|
||||
compareSemver,
|
||||
defaultRequest,
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const MAX_ORIGIN_LENGTH = 512;
|
||||
const ALTA_SUFFIXES = Object.freeze(['avasecurity.com', 'avigilon.com']);
|
||||
|
||||
function policyError(message = 'Invalid Alta deployment origin') {
|
||||
const error = new TypeError(message);
|
||||
error.code = 'INVALID_ALTA_ORIGIN';
|
||||
return error;
|
||||
}
|
||||
|
||||
function canonicalizeAltaOrigin(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ORIGIN_LENGTH) {
|
||||
throw policyError();
|
||||
}
|
||||
if (value !== value.trim() || /[\u0000-\u0020\u007f]/.test(value)) {
|
||||
throw policyError();
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw policyError();
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash || parsed.search) {
|
||||
throw policyError();
|
||||
}
|
||||
if (parsed.port && parsed.port !== '443') {
|
||||
throw policyError();
|
||||
}
|
||||
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
||||
throw policyError();
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const suffix = ALTA_SUFFIXES.find((candidate) => hostname.endsWith(`.${candidate}`));
|
||||
if (!suffix) {
|
||||
throw policyError();
|
||||
}
|
||||
|
||||
const subdomain = hostname.slice(0, -(suffix.length + 1));
|
||||
const labels = subdomain.split('.');
|
||||
if (labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
|
||||
throw policyError();
|
||||
}
|
||||
|
||||
return `https://${hostname}`;
|
||||
}
|
||||
|
||||
function isAltaOrigin(value) {
|
||||
try {
|
||||
canonicalizeAltaOrigin(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameAltaOrigin(candidate, expectedOrigin) {
|
||||
if (typeof candidate !== 'string' || /[\u0000-\u0020\u007f]/.test(candidate)) {
|
||||
throw policyError('Invalid Alta request URL');
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = new URL(candidate);
|
||||
} catch {
|
||||
throw policyError('Invalid Alta request URL');
|
||||
}
|
||||
const expected = canonicalizeAltaOrigin(expectedOrigin);
|
||||
const actual = canonicalizeAltaOrigin(target.origin);
|
||||
if (
|
||||
actual !== expected ||
|
||||
target.origin !== expected ||
|
||||
target.username ||
|
||||
target.password ||
|
||||
target.hash
|
||||
) {
|
||||
throw policyError('Alta request URL changed origin');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALTA_SUFFIXES,
|
||||
MAX_ORIGIN_LENGTH,
|
||||
assertSameAltaOrigin,
|
||||
canonicalizeAltaOrigin,
|
||||
isAltaOrigin,
|
||||
validateAltaOrigin: canonicalizeAltaOrigin,
|
||||
};
|
||||
+42
-50
@@ -532,7 +532,47 @@ button:disabled {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -595,55 +635,7 @@ button:disabled {
|
||||
.update-modal-message {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px 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;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.update-modal-footer {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { SessionStore } = require('../src/session-store');
|
||||
const { AltaClient } = require('../src/alta-client');
|
||||
|
||||
const ORIGIN = 'https://tenant.avasecurity.com';
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
|
||||
function readyStore() {
|
||||
const store = new SessionStore();
|
||||
store.establish(ORIGIN, SENTINEL);
|
||||
return store;
|
||||
}
|
||||
|
||||
function response(data, overrides = {}) {
|
||||
return { status: 200, headers: {}, data, ...overrides };
|
||||
}
|
||||
|
||||
test('uses only stored authority, fixed endpoint paths and hardened transport options', async () => {
|
||||
const calls = [];
|
||||
const transport = async (options) => {
|
||||
calls.push(options);
|
||||
if (options.url.endsWith('/devices')) return response([]);
|
||||
if (options.url.endsWith('/deviceSites')) return response([{ id: 'site-1' }]);
|
||||
return response({ user: 'engineer' });
|
||||
};
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport });
|
||||
|
||||
assert.deepEqual(await client.getDevices(), []);
|
||||
assert.deepEqual(await client.getDeviceSites(), [{ id: 'site-1' }]);
|
||||
assert.deepEqual(await client.getAuthInfo(), { user: 'engineer' });
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.url), [
|
||||
`${ORIGIN}/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/deviceSites`,
|
||||
`${ORIGIN}/api/v1/auth`,
|
||||
]);
|
||||
for (const call of calls) {
|
||||
assert.equal(call.method, 'GET');
|
||||
assert.equal(call.proxy, false);
|
||||
assert.equal(call.maxRedirects, 0);
|
||||
assert.match(call.headers.Cookie, /^va=HERMES_SENTINEL_SECRET$/);
|
||||
assert.ok(call.timeout > 0 && call.timeout <= 10_000);
|
||||
assert.ok(call.maxResponseBytes > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects renderer-supplied URL/cookie parameters before transport', async () => {
|
||||
let calls = 0;
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => { calls += 1; return response([]); },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.getDevices({ deploymentUrl: 'https://evil.example', cookies: [SENTINEL] }),
|
||||
{ code: 'INVALID_ALTA_ARGUMENTS' },
|
||||
);
|
||||
await assert.rejects(client.getAuthInfo(SENTINEL), { code: 'INVALID_ALTA_ARGUMENTS' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('allows only bounded exact-same-origin redirects', async () => {
|
||||
const seen = [];
|
||||
const transport = async (options) => {
|
||||
seen.push(options.url);
|
||||
if (seen.length === 1) {
|
||||
return response('', { status: 307, headers: { location: '/api/v1/devices?cursor=next' } });
|
||||
}
|
||||
return response([]);
|
||||
};
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport });
|
||||
assert.deepEqual(await client.getDevices(), []);
|
||||
assert.deepEqual(seen, [
|
||||
`${ORIGIN}/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/devices?cursor=next`,
|
||||
]);
|
||||
|
||||
for (const location of [
|
||||
'https://evil.example/steal',
|
||||
'https://other.avasecurity.com/api/v1/devices',
|
||||
'http://tenant.avasecurity.com/api/v1/devices',
|
||||
'//evil.example/steal',
|
||||
`https://user:pass@tenant.avasecurity.com/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/devices#not-sent-to-server`,
|
||||
]) {
|
||||
let count = 0;
|
||||
const blocked = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => {
|
||||
count += 1;
|
||||
return response('', { status: 302, headers: { location } });
|
||||
},
|
||||
});
|
||||
await assert.rejects(blocked.getDevices(), { code: 'UNSAFE_ALTA_REDIRECT' });
|
||||
assert.equal(count, 1, location);
|
||||
}
|
||||
});
|
||||
|
||||
test('enforces redirect limit and rejects redirects without locations', async () => {
|
||||
const looping = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxRedirects: 2,
|
||||
transport: async () => response('', { status: 302, headers: { location: '/api/v1/devices' } }),
|
||||
});
|
||||
await assert.rejects(looping.getDevices(), { code: 'TOO_MANY_ALTA_REDIRECTS' });
|
||||
|
||||
const missing = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => response('', { status: 302, headers: {} }),
|
||||
});
|
||||
await assert.rejects(missing.getDevices(), { code: 'UNSAFE_ALTA_REDIRECT' });
|
||||
});
|
||||
|
||||
test('enforces a total timeout even when injected transport does not cooperate', async () => {
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
timeoutMs: 25,
|
||||
transport: async () => new Promise(() => {}),
|
||||
});
|
||||
await assert.rejects(client.getDevices(), { code: 'ALTA_TIMEOUT', timeout: true });
|
||||
});
|
||||
|
||||
test('rejects oversized responses using content-length and actual data size', async () => {
|
||||
const byHeader = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxResponseBytes: 32,
|
||||
transport: async () => response([], { headers: { 'content-length': '33' } }),
|
||||
});
|
||||
await assert.rejects(byHeader.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
|
||||
const byBody = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxResponseBytes: 32,
|
||||
transport: async () => response(JSON.stringify([{ value: 'x'.repeat(40) }])),
|
||||
});
|
||||
await assert.rejects(byBody.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
|
||||
for (const status of [302, 500]) {
|
||||
const oversizedFailure = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxResponseBytes: 32,
|
||||
transport: async () => response('x'.repeat(33), {
|
||||
status,
|
||||
headers: status === 302 ? { location: '/api/v1/devices' } : {},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(oversizedFailure.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed response envelopes, JSON, status and endpoint data shapes', async () => {
|
||||
const cases = [
|
||||
[undefined, 'INVALID_ALTA_RESPONSE'],
|
||||
[response('{broken'), 'INVALID_ALTA_RESPONSE_DATA'],
|
||||
[response([], { status: 500 }), 'ALTA_HTTP_ERROR'],
|
||||
[response({ not: 'an array' }), 'INVALID_ALTA_RESPONSE_DATA'],
|
||||
];
|
||||
for (const [value, code] of cases) {
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport: async () => value });
|
||||
await assert.rejects(client.getDevices(), { code });
|
||||
}
|
||||
|
||||
const authClient = new AltaClient({ sessionStore: readyStore(), transport: async () => response([]) });
|
||||
await assert.rejects(authClient.getAuthInfo(), { code: 'INVALID_ALTA_RESPONSE_DATA' });
|
||||
});
|
||||
|
||||
test('returns a fresh allowlisted error when transport throws an ALTA error with secret-bearing fields', async () => {
|
||||
const source = Object.assign(new Error(`Alta request failed for va=${SENTINEL}`), {
|
||||
code: 'ALTA_TRANSPORT_FAILURE',
|
||||
status: 503,
|
||||
timeout: true,
|
||||
config: { headers: { Cookie: `va=${SENTINEL}` }, data: SENTINEL },
|
||||
request: { headers: { Cookie: `va=${SENTINEL}` }, body: SENTINEL },
|
||||
response: { status: 502, headers: { 'set-cookie': `va=${SENTINEL}` }, data: SENTINEL },
|
||||
body: SENTINEL,
|
||||
});
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => { throw source; },
|
||||
});
|
||||
|
||||
const caught = await client.getDevices().catch((error) => error);
|
||||
assert.notEqual(caught, source);
|
||||
assert.equal(caught.code, 'ALTA_TRANSPORT_FAILURE');
|
||||
assert.equal(caught.message, 'Alta request failed for va=[REDACTED]');
|
||||
assert.equal(caught.status, 502);
|
||||
assert.equal(caught.timeout, true);
|
||||
assert.deepEqual(Object.keys(caught).sort(), ['code', 'status', 'timeout']);
|
||||
for (const field of ['config', 'request', 'response', 'headers', 'data', 'body']) {
|
||||
assert.equal(field in caught, false, field);
|
||||
}
|
||||
assert.doesNotMatch(JSON.stringify(caught), new RegExp(SENTINEL));
|
||||
});
|
||||
|
||||
test('safely formats a transport error whose code getter throws a secret', async () => {
|
||||
const source = new Error('Transport failed safely');
|
||||
Object.defineProperty(source, 'code', {
|
||||
enumerable: true,
|
||||
get() { throw new Error(SENTINEL); },
|
||||
});
|
||||
source.config = { headers: { Cookie: `va=${SENTINEL}` } };
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => { throw source; },
|
||||
});
|
||||
|
||||
const caught = await client.getDevices().catch((error) => error);
|
||||
assert.notEqual(caught, source);
|
||||
assert.equal(caught.code, 'ALTA_ERROR');
|
||||
assert.equal(caught.message, 'Transport failed safely');
|
||||
assert.equal(caught.status, null);
|
||||
assert.equal(caught.timeout, false);
|
||||
assert.deepEqual(Object.keys(caught).sort(), ['code', 'status', 'timeout']);
|
||||
assert.equal('config' in caught, false);
|
||||
assert.doesNotMatch(JSON.stringify(caught), new RegExp(SENTINEL));
|
||||
});
|
||||
|
||||
test('revalidates the stored origin before each request', async () => {
|
||||
const store = { requireSession: () => Object.freeze({ origin: 'https://evil.example', cookie: SENTINEL }) };
|
||||
let called = false;
|
||||
const client = new AltaClient({ sessionStore: store, transport: async () => { called = true; } });
|
||||
await assert.rejects(client.getDevices(), { code: 'INVALID_ALTA_ORIGIN' });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { PassThrough, Readable } = require('node:stream');
|
||||
|
||||
const {
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
computeCookieProof,
|
||||
computeServerProof,
|
||||
generatePairingSecret,
|
||||
readJsonBody,
|
||||
createRequestLimiter
|
||||
} = require('../src/bridge-auth');
|
||||
|
||||
const EXTENSION_ORIGIN = 'chrome-extension://onbkfpbggekakjddomjjnboippimlmch';
|
||||
const CLIENT_NONCE = Buffer.alloc(32, 1).toString('base64url');
|
||||
const protect = (plaintext) => Buffer.from(`protected:${plaintext}`, 'utf8');
|
||||
const unprotect = (ciphertext) => {
|
||||
const value = Buffer.from(ciphertext).toString('utf8');
|
||||
if (!value.startsWith('protected:')) throw new Error('bad ciphertext');
|
||||
return value.slice('protected:'.length);
|
||||
};
|
||||
|
||||
function createAuth(options = {}) {
|
||||
return new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, protect, unprotect, ...options });
|
||||
}
|
||||
|
||||
test('pairing secrets are random and persisted only in a versioned protected envelope', () => {
|
||||
const first = generatePairingSecret();
|
||||
const second = generatePairingSecret();
|
||||
assert.match(first, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.notEqual(first, second);
|
||||
|
||||
const auth = createAuth();
|
||||
const { secret, envelope } = auth.rotate();
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['algorithm', 'ciphertext', 'digest', 'version']);
|
||||
assert.equal(envelope.version, 2);
|
||||
assert.equal(envelope.algorithm, 'electron-safe-storage');
|
||||
assert.equal(JSON.stringify(envelope).includes(secret), false);
|
||||
assert.equal(Buffer.from(envelope.ciphertext, 'base64').toString('utf8').includes(secret), true,
|
||||
'test protector is intentionally transparent after decoding; disk JSON itself is never plaintext');
|
||||
});
|
||||
|
||||
test('protected envelope restores server authentication without request-time scrypt', () => {
|
||||
let protectCalls = 0;
|
||||
let unprotectCalls = 0;
|
||||
const first = new BridgeAuth({
|
||||
protect(value) { protectCalls += 1; return protect(value); },
|
||||
unprotect(value) { unprotectCalls += 1; return unprotect(value); }
|
||||
});
|
||||
const { secret, envelope } = first.rotate();
|
||||
const restored = new BridgeAuth({
|
||||
envelope: JSON.parse(JSON.stringify(envelope)),
|
||||
protect,
|
||||
unprotect(value) { unprotectCalls += 1; return unprotect(value); }
|
||||
});
|
||||
const challenge = restored.issueChallenge(CLIENT_NONCE);
|
||||
|
||||
assert.equal(protectCalls, 1);
|
||||
assert.equal(unprotectCalls, 1);
|
||||
assert.equal(challenge.serverProof, computeServerProof(secret, CLIENT_NONCE, challenge.serverNonce));
|
||||
assert.equal(Object.hasOwn(challenge, 'secret'), false);
|
||||
});
|
||||
|
||||
test('challenge proof and cookie proof are domain-separated, constant-time authenticated, and one-time', () => {
|
||||
const auth = createAuth();
|
||||
const secret = auth.rotate().secret;
|
||||
const challenge = auth.issueChallenge(CLIENT_NONCE);
|
||||
const cookieRequest = {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: challenge.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'synthetic-cookie'
|
||||
};
|
||||
cookieRequest.proof = computeCookieProof(secret, cookieRequest);
|
||||
|
||||
assert.notEqual(challenge.serverProof, cookieRequest.proof);
|
||||
assert.equal(auth.authenticateCookie(cookieRequest), true);
|
||||
assert.equal(auth.authenticateCookie(cookieRequest), false, 'challenge replay must fail');
|
||||
});
|
||||
|
||||
test('challenge cache is bounded, expires entries, and rejects malformed nonces', () => {
|
||||
let now = 100;
|
||||
const auth = createAuth({ maxChallenges: 1, challengeTtlMs: 50, now: () => now });
|
||||
auth.rotate();
|
||||
auth.issueChallenge(CLIENT_NONCE);
|
||||
assert.throws(() => auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url')),
|
||||
(error) => error.code === 'CHALLENGE_CAPACITY');
|
||||
now = 151;
|
||||
const replacement = auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url'));
|
||||
assert.match(replacement.serverNonce, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.throws(() => auth.issueChallenge('short'), (error) => error.code === 'INVALID_NONCE');
|
||||
});
|
||||
|
||||
test('expired and forged cookie proofs fail and consume the one-time challenge', () => {
|
||||
let now = 100;
|
||||
const auth = createAuth({ challengeTtlMs: 50, now: () => now });
|
||||
const secret = auth.rotate().secret;
|
||||
const expired = auth.issueChallenge(CLIENT_NONCE);
|
||||
now = 151;
|
||||
assert.equal(auth.authenticateCookie({
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: expired.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: computeCookieProof(secret, {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: expired.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie'
|
||||
})
|
||||
}), false);
|
||||
|
||||
now = 200;
|
||||
const forged = auth.issueChallenge(CLIENT_NONCE);
|
||||
const request = {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: forged.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: 'A'.repeat(43)
|
||||
};
|
||||
assert.equal(auth.authenticateCookie(request), false);
|
||||
request.proof = computeCookieProof(secret, request);
|
||||
assert.equal(auth.authenticateCookie(request), false, 'forged attempt consumes challenge');
|
||||
});
|
||||
|
||||
test('rotation clears outstanding challenges and revoke disables challenge issuance', () => {
|
||||
const auth = createAuth();
|
||||
auth.rotate();
|
||||
const prior = auth.issueChallenge(CLIENT_NONCE);
|
||||
auth.rotate();
|
||||
assert.equal(auth.authenticateCookie({
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: prior.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: 'A'.repeat(43)
|
||||
}), false);
|
||||
auth.revoke();
|
||||
assert.throws(() => auth.issueChallenge(CLIENT_NONCE), (error) => error.code === 'PAIRING_UNAVAILABLE');
|
||||
assert.equal(auth.envelope, null);
|
||||
});
|
||||
|
||||
test('invalid origin and protected-envelope dependencies fail closed', () => {
|
||||
assert.throws(() => new BridgeAuth({ expectedOrigin: 'https://example.test', protect, unprotect }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN');
|
||||
assert.throws(() => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', protect, unprotect }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN');
|
||||
assert.throws(() => new BridgeAuth(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE');
|
||||
assert.throws(() => new BridgeAuth({ protect, unprotect, envelope: { version: 1, algorithm: 'scrypt' } }),
|
||||
(error) => error.code === 'INVALID_SECRET_ENVELOPE');
|
||||
});
|
||||
|
||||
test('readJsonBody accepts a bounded JSON object and rejects malformed or oversized input', async () => {
|
||||
assert.deepEqual(await readJsonBody(Readable.from(['{"ok":true}']), { maxBytes: 64 }), { ok: true });
|
||||
await assert.rejects(readJsonBody(Readable.from(['{"nope"']), { maxBytes: 64 }), (error) => error.code === 'MALFORMED_JSON');
|
||||
await assert.rejects(readJsonBody(Readable.from(['{"value":"', 'x'.repeat(100), '"}']), { maxBytes: 32 }),
|
||||
(error) => error.code === 'BODY_TOO_LARGE');
|
||||
await assert.rejects(readJsonBody(Readable.from(['[]']), { maxBytes: 64 }), (error) => error.code === 'INVALID_JSON_BODY');
|
||||
});
|
||||
|
||||
test('readJsonBody aborts a slow body at its absolute deadline', async () => {
|
||||
const stream = new PassThrough();
|
||||
const pending = readJsonBody(stream, { maxBytes: 64, deadlineMs: 15 });
|
||||
stream.write('{');
|
||||
await assert.rejects(pending, (error) => error.code === 'BODY_DEADLINE_EXCEEDED');
|
||||
});
|
||||
|
||||
test('request limiter rejects excess concurrent work before it begins', async () => {
|
||||
const limiter = createRequestLimiter({ maxConcurrent: 1 });
|
||||
let release;
|
||||
const active = limiter.run(() => new Promise((resolve) => { release = resolve; }));
|
||||
let entered = false;
|
||||
await assert.rejects(limiter.run(async () => { entered = true; }), (error) => error.code === 'TOO_MANY_REQUESTS');
|
||||
assert.equal(entered, false);
|
||||
release('done');
|
||||
assert.equal(await active, 'done');
|
||||
assert.equal(limiter.active, 0);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'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 { spawnSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
const readJson = (name) => JSON.parse(read(name));
|
||||
|
||||
function writeKit(root, { version = '1.1.0', legacy = false } = {}) {
|
||||
const extension = path.join(root, 'chrome-extension');
|
||||
fs.mkdirSync(extension, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'AltaCameraProxy.exe'), 'synthetic executable');
|
||||
fs.writeFileSync(path.join(root, 'aware-cam-proxy.exe'), 'synthetic helper');
|
||||
for (const name of ['popup.html', 'popup.js', 'popup.css', 'options.html', 'options.js', 'options.css']) {
|
||||
fs.writeFileSync(path.join(extension, name), legacy && name === 'popup.js' ? 'apt-local-bridge-token' : `'use strict';`);
|
||||
}
|
||||
fs.writeFileSync(path.join(extension, 'manifest.json'), JSON.stringify({
|
||||
manifest_version: 3,
|
||||
name: 'Alta Proxy Tool Bridge',
|
||||
version,
|
||||
}));
|
||||
}
|
||||
|
||||
test('application and extension versions and supported dependencies stay coordinated', () => {
|
||||
const pkg = readJson('package.json');
|
||||
const lock = readJson('package-lock.json');
|
||||
const manifest = readJson('chrome-extension/manifest.json');
|
||||
|
||||
assert.equal(pkg.version, '1.1.0');
|
||||
assert.equal(manifest.version, pkg.version);
|
||||
assert.equal(lock.version, pkg.version);
|
||||
assert.equal(lock.packages[''].version, pkg.version);
|
||||
assert.deepEqual(pkg.dependencies, { axios: '1.19.0' });
|
||||
assert.deepEqual(pkg.devDependencies, {
|
||||
electron: '43.4.1',
|
||||
'electron-builder': '26.15.3',
|
||||
});
|
||||
for (const removed of ['crypto-js', 'electron-packager']) {
|
||||
assert.equal(pkg.dependencies?.[removed], undefined);
|
||||
assert.equal(pkg.devDependencies?.[removed], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('build remains Windows portable without misleading signing overrides', () => {
|
||||
const pkg = readJson('package.json');
|
||||
assert.equal(pkg.build.win.target, 'portable');
|
||||
assert.equal(pkg.build.portable.artifactName, 'AltaCameraProxy-${version}-portable.exe');
|
||||
assert.equal(Object.hasOwn(pkg.build.win, 'signAndEditExecutable'), false);
|
||||
assert.equal(Object.hasOwn(pkg.build, 'forceCodeSigning'), false);
|
||||
assert.equal(pkg.scripts['audit:prod'], 'npm audit --omit=dev --audit-level=low');
|
||||
assert.match(pkg.scripts.check, /verify-kit/);
|
||||
});
|
||||
|
||||
test('GitPeji CI is build-only and runs all hardening gates on Node 22', () => {
|
||||
const workflow = read('.gitea/workflows/ci.yml');
|
||||
assert.match(workflow, /actions\/checkout@v4/);
|
||||
assert.match(workflow, /actions\/setup-node@v4/);
|
||||
assert.match(workflow, /node-version:\s*['"]?22['"]?/);
|
||||
for (const command of ['npm ci', 'npm run check', 'npm run audit:prod', 'npm run build-test']) {
|
||||
assert.ok(workflow.includes(command), `missing CI command: ${command}`);
|
||||
}
|
||||
assert.match(workflow, /verify-kit\.js\s+--build\s+dist\/win-unpacked/);
|
||||
assert.doesNotMatch(workflow, /npm\s+(?:publish|run\s+(?:release|deploy))|actions\/(?:upload-release|deploy)|git\s+push/i);
|
||||
assert.doesNotMatch(workflow, /secrets\./);
|
||||
});
|
||||
|
||||
test('kit verifier accepts a complete synthetic kit and rejects version drift and legacy strings', () => {
|
||||
const valid = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-kit-valid-'));
|
||||
writeKit(valid);
|
||||
const accepted = spawnSync(process.execPath, ['scripts/verify-kit.js', valid], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.equal(accepted.status, 0, accepted.stderr || accepted.stdout);
|
||||
|
||||
const drifted = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-kit-drift-'));
|
||||
writeKit(drifted, { version: '1.0.0' });
|
||||
const rejectedDrift = spawnSync(process.execPath, ['scripts/verify-kit.js', drifted], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.notEqual(rejectedDrift.status, 0);
|
||||
assert.match(rejectedDrift.stderr, /version/i);
|
||||
|
||||
const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-kit-legacy-'));
|
||||
writeKit(legacy, { legacy: true });
|
||||
const rejectedLegacy = spawnSync(process.execPath, ['scripts/verify-kit.js', legacy], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.notEqual(rejectedLegacy.status, 0);
|
||||
assert.match(rejectedLegacy.stderr, /forbidden/i);
|
||||
});
|
||||
|
||||
test('kit verifier rejects unsafe relative archive paths', () => {
|
||||
const { normalizeRelative } = require('../scripts/verify-kit');
|
||||
for (const unsafe of ['../escape', 'folder/../../escape', '/absolute', 'C:\\absolute']) {
|
||||
assert.throws(() => normalizeRelative(unsafe), /unsafe/i);
|
||||
}
|
||||
assert.equal(normalizeRelative('chrome-extension\\manifest.json'), 'chrome-extension/manifest.json');
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
formatAltaError,
|
||||
sanitizeErrorMessage,
|
||||
} = require('../src/error-redaction');
|
||||
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
|
||||
function assertNoSecret(value) {
|
||||
const serialized = JSON.stringify(value);
|
||||
assert.equal(serialized.includes(SENTINEL), false, serialized);
|
||||
assert.equal(serialized.includes('Cookie'), false, serialized);
|
||||
assert.equal(serialized.includes('Authorization'), false, serialized);
|
||||
}
|
||||
|
||||
test('formats only an allowlist of bounded safe fields', () => {
|
||||
const error = new Error('request failed safely');
|
||||
error.code = 'ECONNRESET';
|
||||
error.timeout = false;
|
||||
error.response = { status: 502 };
|
||||
|
||||
const formatted = formatAltaError('getDevices', error, { secrets: [SENTINEL] });
|
||||
assert.deepEqual(formatted, {
|
||||
operation: 'getDevices',
|
||||
code: 'ECONNRESET',
|
||||
status: 502,
|
||||
timeout: false,
|
||||
message: 'request failed safely',
|
||||
});
|
||||
assert.deepEqual(Object.keys(formatted), ['operation', 'code', 'status', 'timeout', 'message']);
|
||||
});
|
||||
|
||||
test('never serializes Axios config, request, headers, URL, body, cause or response data', () => {
|
||||
const error = new Error(`failed with va=${SENTINEL}`);
|
||||
error.code = 'ERR_BAD_RESPONSE';
|
||||
error.config = {
|
||||
url: `https://evil.example/?token=${SENTINEL}`,
|
||||
headers: { Cookie: `va=${SENTINEL}`, Authorization: `Bearer ${SENTINEL}` },
|
||||
data: { token: SENTINEL },
|
||||
};
|
||||
error.request = { rawHeaders: `Cookie: va=${SENTINEL}` };
|
||||
error.response = {
|
||||
status: 401,
|
||||
headers: { 'set-cookie': `va=${SENTINEL}` },
|
||||
data: { message: SENTINEL, nested: { secret: SENTINEL } },
|
||||
};
|
||||
error.cause = { message: SENTINEL, headers: { Cookie: SENTINEL } };
|
||||
|
||||
const formatted = formatAltaError('getDevices', error, { secrets: [SENTINEL] });
|
||||
assertNoSecret(formatted);
|
||||
assert.deepEqual(Object.keys(formatted), ['operation', 'code', 'status', 'timeout', 'message']);
|
||||
assert.equal(formatted.message, 'failed with va=[REDACTED]');
|
||||
});
|
||||
|
||||
test('redacts cookie and bearer patterns without needing the exact secret', () => {
|
||||
for (const message of [
|
||||
`Cookie: va=${SENTINEL}; other=value`,
|
||||
`va=${SENTINEL}`,
|
||||
`Authorization: Bearer ${SENTINEL}`,
|
||||
`Bearer ${SENTINEL}`,
|
||||
]) {
|
||||
const clean = sanitizeErrorMessage(message);
|
||||
assertNoSecret({ message: clean });
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds and sanitizes operation, code, status and message', () => {
|
||||
const error = {
|
||||
message: `line one\r\nCookie: va=${SENTINEL} ${'x'.repeat(1000)}`,
|
||||
code: 'BAD CODE WITH SPACES AND SECRET_' + SENTINEL,
|
||||
response: { status: 9999 },
|
||||
};
|
||||
const formatted = formatAltaError(`unsafe operation ${SENTINEL}`, error, { secrets: [SENTINEL] });
|
||||
|
||||
assertNoSecret(formatted);
|
||||
assert.ok(formatted.operation.length <= 64);
|
||||
assert.equal(formatted.code, 'ALTA_ERROR');
|
||||
assert.equal(formatted.status, null);
|
||||
assert.ok(formatted.message.length <= 240);
|
||||
assert.equal(/[\r\n]/.test(formatted.message), false);
|
||||
});
|
||||
|
||||
test('handles hostile accessors, cycles and non-error values without leaking or throwing', () => {
|
||||
const hostile = {};
|
||||
Object.defineProperty(hostile, 'message', { get() { throw new Error(SENTINEL); } });
|
||||
Object.defineProperty(hostile, 'code', { get() { throw new Error(SENTINEL); } });
|
||||
hostile.self = hostile;
|
||||
|
||||
const formatted = formatAltaError('request', hostile, { secrets: [SENTINEL] });
|
||||
assertNoSecret(formatted);
|
||||
assert.equal(formatted.message, 'Alta request failed');
|
||||
assert.equal(formatted.code, 'ALTA_ERROR');
|
||||
|
||||
assertNoSecret(formatAltaError('request', SENTINEL, { secrets: [SENTINEL] }));
|
||||
assertNoSecret(formatAltaError('request', null, { secrets: [SENTINEL] }));
|
||||
});
|
||||
|
||||
test('does not leak a supplied secret through valid-looking operation or code fields', () => {
|
||||
const formatted = formatAltaError(SENTINEL, {
|
||||
code: SENTINEL,
|
||||
message: 'failed',
|
||||
}, { secrets: [SENTINEL] });
|
||||
assertNoSecret(formatted);
|
||||
assert.equal(formatted.operation, 'altaRequest');
|
||||
assert.equal(formatted.code, 'ALTA_ERROR');
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { APT_EXTENSION_ID, APT_EXTENSION_ORIGIN } = require('../src/bridge-auth');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const EXTENSION = path.join(ROOT, 'chrome-extension');
|
||||
const OLD_TOKEN = 'apt-local-' + 'bridge-token';
|
||||
const EXPECTED_ID = 'onbkfpbggekakjddomjjnboippimlmch';
|
||||
|
||||
function read(name) {
|
||||
return fs.readFileSync(path.join(EXTENSION, name), 'utf8');
|
||||
}
|
||||
|
||||
function extensionIdFromKey(key) {
|
||||
const digest = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest().subarray(0, 16);
|
||||
return [...digest].map((byte) =>
|
||||
String.fromCharCode(97 + (byte >> 4), 97 + (byte & 0x0f))
|
||||
).join('');
|
||||
}
|
||||
|
||||
function makeElement() {
|
||||
const listeners = {};
|
||||
return {
|
||||
textContent: '',
|
||||
className: '',
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
checked: false,
|
||||
value: '',
|
||||
addEventListener(type, listener) { listeners[type] = listener; },
|
||||
async dispatch(type) { return listeners[type]?.({ preventDefault() {} }); }
|
||||
};
|
||||
}
|
||||
|
||||
function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null, validServerProof = true } = {}) {
|
||||
const elements = Object.fromEntries(
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn']
|
||||
.map((id) => [id, makeElement()])
|
||||
);
|
||||
const clipboardWrites = [];
|
||||
const fetchCalls = [];
|
||||
let cookieReads = 0;
|
||||
const chromeApi = {
|
||||
storage: { local: { get: async () => paired ? { aptPairingSecret: 'A'.repeat(43) } : {} } },
|
||||
tabs: { query: async () => [{ url: 'https://customer.avasecurity.com/devices' }] },
|
||||
cookies: { get: async () => { cookieReads += 1; return { value: 'sensitive-va-token' }; } },
|
||||
runtime: { openOptionsPage() {} }
|
||||
};
|
||||
const navigatorApi = {
|
||||
clipboard: {
|
||||
async writeText(value) {
|
||||
if (clipboardReject) throw clipboardReject;
|
||||
clipboardWrites.push(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
const documentApi = { getElementById: (id) => elements[id] };
|
||||
const { createPopupController } = require('../chrome-extension/popup.js');
|
||||
const controller = createPopupController({
|
||||
chromeApi,
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
confirmCopy: () => confirmCopy,
|
||||
cryptoApi: crypto.webcrypto,
|
||||
fetchImpl: async (...args) => {
|
||||
fetchCalls.push(args);
|
||||
if (args[0].endsWith('/challenge')) {
|
||||
const { clientNonce } = JSON.parse(args[1].body);
|
||||
const serverNonce = 'B'.repeat(43);
|
||||
const canonical = JSON.stringify(['apt-server-challenge-v1', clientNonce, serverNonce]);
|
||||
const serverProof = validServerProof
|
||||
? crypto.createHmac('sha256', 'A'.repeat(43)).update(canonical).digest('base64url')
|
||||
: 'C'.repeat(43);
|
||||
return { ok: true, json: async () => ({ success: true, serverNonce, serverProof }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
}
|
||||
});
|
||||
return { controller, elements, clipboardWrites, fetchCalls, get cookieReads() { return cookieReads; } };
|
||||
}
|
||||
|
||||
test('manifest commits only a public key, stable ID, local storage, and exact loopback host access', () => {
|
||||
const manifest = JSON.parse(read('manifest.json'));
|
||||
assert.equal(extensionIdFromKey(manifest.key), EXPECTED_ID);
|
||||
assert.equal(APT_EXTENSION_ID, EXPECTED_ID);
|
||||
assert.equal(APT_EXTENSION_ORIGIN, `chrome-extension://${EXPECTED_ID}`);
|
||||
assert.ok(manifest.permissions.includes('storage'));
|
||||
assert.equal(manifest.options_page, 'options.html');
|
||||
assert.ok(manifest.host_permissions.includes('http://127.0.0.1:18247/*'));
|
||||
assert.equal(manifest.host_permissions.some((entry) => entry.includes('localhost')), false);
|
||||
assert.equal(/PRIVATE KEY/.test(manifest.key), false);
|
||||
});
|
||||
|
||||
test('extension source contains no old token and sends only to the exact bridge endpoint', () => {
|
||||
const sources = ['manifest.json', 'popup.js', 'popup.html', 'options.js', 'options.html']
|
||||
.map(read).join('\n');
|
||||
assert.equal(sources.includes(OLD_TOKEN), false);
|
||||
assert.match(read('popup.js'), /http:\/\/127\.0\.0\.1:18247\/cookie/);
|
||||
assert.doesNotMatch(sources, /http:\/\/(?:localhost|\[::1\]|0\.0\.0\.0):18247/);
|
||||
assert.doesNotMatch(sources, /console\.(?:log|debug|info)\s*\(/);
|
||||
});
|
||||
|
||||
test('popup stays disabled while unpaired and directs the user to pairing options', async () => {
|
||||
const harness = makePopupHarness({ paired: false });
|
||||
await harness.controller.init();
|
||||
assert.equal(harness.elements.sendBtn.disabled, true);
|
||||
assert.equal(harness.elements.copyBtn.disabled, true);
|
||||
assert.match(harness.elements.pairingInfo.textContent, /Not paired/i);
|
||||
assert.equal(harness.elements.openOptionsBtn.hidden, false);
|
||||
});
|
||||
|
||||
test('paired popup enables preserved actions only on a supported Alta HTTPS tab', async () => {
|
||||
const harness = makePopupHarness({ paired: true });
|
||||
await harness.controller.init();
|
||||
assert.equal(harness.elements.sendBtn.textContent || 'Send to APT', 'Send to APT');
|
||||
assert.equal(harness.elements.copyBtn.textContent || 'Copy VA Token', 'Copy VA Token');
|
||||
assert.equal(harness.elements.sendBtn.disabled, false);
|
||||
assert.equal(harness.elements.copyBtn.disabled, false);
|
||||
assert.match(harness.elements.pairingInfo.textContent, /Paired/i);
|
||||
});
|
||||
|
||||
test('deployment detection rejects non-HTTPS, bare, and lookalike Alta hosts', () => {
|
||||
const { isSupportedDeploymentUrl } = require('../chrome-extension/popup.js');
|
||||
assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com/path'), true);
|
||||
assert.equal(isSupportedDeploymentUrl('http://customer.avasecurity.com/path'), false);
|
||||
assert.equal(isSupportedDeploymentUrl('https://avasecurity.com/path'), false);
|
||||
assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com.evil.test/path'), false);
|
||||
});
|
||||
|
||||
test('Send to APT authenticates the listener before reading or sending the cookie', async () => {
|
||||
const harness = makePopupHarness({ paired: true });
|
||||
await harness.controller.init();
|
||||
await harness.controller.sendToApt();
|
||||
assert.equal(harness.fetchCalls.length, 2);
|
||||
const [challengeUrl, challengeRequest] = harness.fetchCalls[0];
|
||||
assert.equal(challengeUrl, 'http://127.0.0.1:18247/challenge');
|
||||
assert.equal(JSON.stringify(challengeRequest).includes('sensitive-va-token'), false);
|
||||
assert.equal(JSON.stringify(challengeRequest).includes('A'.repeat(43)), false);
|
||||
const [url, request] = harness.fetchCalls[1];
|
||||
assert.equal(url, 'http://127.0.0.1:18247/cookie');
|
||||
assert.equal(Object.keys(request.headers).some((name) => /pairing/i.test(name)), false);
|
||||
const body = JSON.parse(request.body);
|
||||
assert.equal(body.deploymentUrl, 'https://customer.avasecurity.com');
|
||||
assert.equal(body.cookieValue, 'sensitive-va-token');
|
||||
assert.match(body.clientNonce, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(body.serverNonce, 'B'.repeat(43));
|
||||
assert.match(body.proof, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(request.body.includes('A'.repeat(43)), false);
|
||||
});
|
||||
|
||||
test('a port-squatting fake server with an invalid proof receives no cookie or pairing secret', async () => {
|
||||
const harness = makePopupHarness({ paired: true, validServerProof: false });
|
||||
await harness.controller.init();
|
||||
await harness.controller.sendToApt();
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
assert.equal(harness.fetchCalls[0][0], 'http://127.0.0.1:18247/challenge');
|
||||
assert.equal(harness.cookieReads, 0);
|
||||
const network = JSON.stringify(harness.fetchCalls);
|
||||
assert.equal(network.includes('sensitive-va-token'), false);
|
||||
assert.equal(network.includes('A'.repeat(43)), false);
|
||||
});
|
||||
|
||||
test('copy cancellation occurs before cookie access and never writes the token', async () => {
|
||||
const harness = makePopupHarness({ paired: true, confirmCopy: false });
|
||||
await harness.controller.init();
|
||||
await harness.controller.copyToken();
|
||||
assert.equal(harness.cookieReads, 0);
|
||||
assert.deepEqual(harness.clipboardWrites, []);
|
||||
assert.match(harness.elements.statusMsg.textContent, /cancelled/i);
|
||||
});
|
||||
|
||||
test('confirmed copy warns about clipboard history and reports success without exposing token', async () => {
|
||||
const harness = makePopupHarness({ paired: true, confirmCopy: true });
|
||||
await harness.controller.init();
|
||||
assert.match(harness.elements.copyWarning.textContent, /clipboard (?:history|sync)/i);
|
||||
await harness.controller.copyToken();
|
||||
assert.deepEqual(harness.clipboardWrites, ['sensitive-va-token']);
|
||||
assert.match(harness.elements.statusMsg.textContent, /copied/i);
|
||||
assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false);
|
||||
});
|
||||
|
||||
test('clipboard permission denial is handled without leaking the token', async () => {
|
||||
const harness = makePopupHarness({
|
||||
paired: true,
|
||||
confirmCopy: true,
|
||||
clipboardReject: new Error('NotAllowedError')
|
||||
});
|
||||
await harness.controller.init();
|
||||
await harness.controller.copyToken();
|
||||
assert.match(harness.elements.statusMsg.textContent, /could not copy/i);
|
||||
assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false);
|
||||
});
|
||||
|
||||
test('options UI stores a validated pairing secret locally and can forget pairing', () => {
|
||||
const html = read('options.html');
|
||||
const js = read('options.js');
|
||||
assert.match(html, /pairingSecret/);
|
||||
assert.match(html, /type="password"/);
|
||||
assert.match(js, /chrome\.storage\.local\.set/);
|
||||
assert.match(js, /chrome\.storage\.local\.remove/);
|
||||
assert.match(js, /aptPairingSecret/);
|
||||
assert.doesNotMatch(js, /console\./);
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": 17,
|
||||
"tag_name": "v1.0.0",
|
||||
"target_commitish": "main",
|
||||
"name": "Alta Proxy Tool v1.0.0",
|
||||
"body": "Initial GitPeji release",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2026-08-19T12:30:00Z",
|
||||
"published_at": "2026-08-19T12:34:56Z",
|
||||
"html_url": "https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases/tag/v1.0.0",
|
||||
"tarball_url": "https://git.pejicorp.com/peji/Alta-Proxy-Tool/archive/v1.0.0.tar.gz",
|
||||
"zipball_url": "https://git.pejicorp.com/peji/Alta-Proxy-Tool/archive/v1.0.0.zip",
|
||||
"assets": []
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const MODULE_PATH = '../src/proxy-launch';
|
||||
const APPROVED_DIRECTORY = 'C:\\Program Files\\Alta Proxy Tool';
|
||||
const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe';
|
||||
const VALID_HOST = 'tenant.avasecurity.com';
|
||||
const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const VALID_USERNAME = 'proxy.operator+apt@example.com';
|
||||
|
||||
function loadModule() {
|
||||
return require(MODULE_PATH);
|
||||
}
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
constructor(pid, killResult = true) {
|
||||
super();
|
||||
this.pid = pid;
|
||||
this.killResult = killResult;
|
||||
this.killCalls = [];
|
||||
this.killError = null;
|
||||
}
|
||||
|
||||
kill(signal) {
|
||||
this.killCalls.push(signal);
|
||||
if (this.killError) throw this.killError;
|
||||
return this.killResult;
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness({ helperExists = true, children = [new FakeChild(4101)] } = {}) {
|
||||
const calls = [];
|
||||
let childIndex = 0;
|
||||
const spawn = (...args) => {
|
||||
calls.push(args);
|
||||
return children[childIndex++];
|
||||
};
|
||||
const fsStub = { existsSync: (candidate) => helperExists && candidate === APPROVED_HELPER };
|
||||
const { createProxyManager } = loadModule();
|
||||
const manager = createProxyManager({
|
||||
appDirectory: APPROVED_DIRECTORY,
|
||||
fs: fsStub,
|
||||
spawn,
|
||||
platform: 'win32',
|
||||
now: () => 1_777_777_777_777
|
||||
});
|
||||
return { manager, calls, children };
|
||||
}
|
||||
|
||||
function validRequest(overrides = {}) {
|
||||
return {
|
||||
deploymentHost: VALID_HOST,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
username: VALID_USERNAME,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('exports the proxy manager module', () => {
|
||||
assert.doesNotThrow(() => loadModule());
|
||||
});
|
||||
|
||||
test('launches the approved helper directly in a visible interactive console', () => {
|
||||
const { manager, calls } = createHarness();
|
||||
|
||||
const result = manager.launchProxy(validRequest());
|
||||
|
||||
assert.deepEqual(calls, [[
|
||||
APPROVED_HELPER,
|
||||
['-a', VALID_HOST, '-u', VALID_USERNAME, '-d', VALID_DEVICE_ID],
|
||||
{
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
windowsHide: false
|
||||
}
|
||||
]]);
|
||||
assert.deepEqual(result, {
|
||||
success: true,
|
||||
processId: 4101,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
startedAt: 1_777_777_777_777,
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
test('passes username punctuation literally in argv without invoking a shell', () => {
|
||||
const username = 'proxy+apt&literal|name@example.com';
|
||||
const { manager, calls } = createHarness();
|
||||
|
||||
manager.launchProxy(validRequest({ username }));
|
||||
|
||||
assert.equal(calls[0][1][3], username);
|
||||
assert.equal(calls[0][2].shell, false);
|
||||
assert.equal(calls[0][2].stdio, 'inherit');
|
||||
assert.notEqual(calls[0][2].stdio, 'ignore');
|
||||
});
|
||||
|
||||
test('rejects the CRLF calc.exe reproducer in every structured input', () => {
|
||||
const { manager, calls } = createHarness();
|
||||
const attacks = [
|
||||
{ deploymentHost: `${VALID_HOST}\r\ncalc.exe` },
|
||||
{ deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` },
|
||||
{ username: `${VALID_USERNAME}\r\ncalc.exe` },
|
||||
{ username: `${VALID_USERNAME}\0calc.exe` }
|
||||
];
|
||||
|
||||
for (const attack of attacks) {
|
||||
assert.throws(() => manager.launchProxy(validRequest(attack)), /invalid|must not contain/i);
|
||||
}
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('rejects shell metacharacters in deployment hosts and device identifiers', () => {
|
||||
const { manager, calls } = createHarness();
|
||||
for (const deploymentHost of [
|
||||
'tenant.avasecurity.com&calc.exe',
|
||||
'tenant.avasecurity.com|calc.exe',
|
||||
'https://tenant.avasecurity.com',
|
||||
'tenant.avasecurity.com/path',
|
||||
'tenant.avasecurity.com:443'
|
||||
]) {
|
||||
assert.throws(() => manager.launchProxy(validRequest({ deploymentHost })), /deployment host/i);
|
||||
}
|
||||
for (const deviceId of [
|
||||
`${VALID_DEVICE_ID}&calc.exe`,
|
||||
`${VALID_DEVICE_ID}|calc.exe`,
|
||||
'$(calc.exe)'
|
||||
]) {
|
||||
assert.throws(() => manager.launchProxy(validRequest({ deviceId })), /device identifier/i);
|
||||
}
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('accepts only strict canonical UUID device identifiers', () => {
|
||||
const invalidIds = [
|
||||
'',
|
||||
'123e4567-e89b-12d3-a456-42661417400',
|
||||
'123e4567e89b42d3a456426614174000',
|
||||
'123e4567-e89b-02d3-a456-426614174000',
|
||||
'123e4567-e89b-42d3-c456-426614174000',
|
||||
'g23e4567-e89b-42d3-a456-426614174000',
|
||||
'a'.repeat(37)
|
||||
];
|
||||
|
||||
for (const deviceId of invalidIds) {
|
||||
const { manager } = createHarness();
|
||||
assert.throws(() => manager.launchProxy(validRequest({ deviceId })), /device identifier/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts only bounded Alta subdomain hostnames from trusted session state', () => {
|
||||
const invalidHosts = [
|
||||
'',
|
||||
'avasecurity.com',
|
||||
'avigilon.com',
|
||||
'evil.example',
|
||||
'tenant.avasecurity.com.evil.example',
|
||||
'.avasecurity.com',
|
||||
'-tenant.avasecurity.com',
|
||||
'tenant..avasecurity.com',
|
||||
'tenant_avasecurity.com',
|
||||
`tenant.${'a'.repeat(240)}.avasecurity.com`
|
||||
];
|
||||
|
||||
for (const deploymentHost of invalidHosts) {
|
||||
const { manager } = createHarness();
|
||||
assert.throws(() => manager.launchProxy(validRequest({ deploymentHost })), /deployment host/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes a valid Alta hostname to lowercase', () => {
|
||||
const { manager, calls } = createHarness();
|
||||
manager.launchProxy(validRequest({ deploymentHost: 'Tenant.AVIGILON.com' }));
|
||||
assert.equal(calls[0][1][1], 'tenant.avigilon.com');
|
||||
});
|
||||
|
||||
test('rejects empty, oversized, non-string, and control-character usernames', () => {
|
||||
for (const username of ['', 'x'.repeat(255), 42, 'user\nname', 'user\rname', 'user\0name', 'user\u007fname']) {
|
||||
const { manager } = createHarness();
|
||||
assert.throws(() => manager.launchProxy(validRequest({ username })), /username/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the fixed helper is missing and never spawns', () => {
|
||||
const { manager, calls } = createHarness({ helperExists: false });
|
||||
assert.throws(() => manager.launchProxy(validRequest()), /helper.*not found/i);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('requires an absolute approved application directory and Windows platform', () => {
|
||||
const { createProxyManager } = loadModule();
|
||||
const dependencies = { fs: { existsSync: () => true }, spawn: () => new FakeChild(1) };
|
||||
|
||||
assert.throws(
|
||||
() => createProxyManager({ appDirectory: '..\\untrusted', platform: 'win32', ...dependencies }),
|
||||
/absolute/i
|
||||
);
|
||||
assert.throws(
|
||||
() => createProxyManager({ appDirectory: '/opt/apt', platform: 'linux', ...dependencies }),
|
||||
/platform/i
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a bounded spawn failure without credential redaction machinery', () => {
|
||||
const { createProxyManager } = loadModule();
|
||||
const manager = createProxyManager({
|
||||
appDirectory: APPROVED_DIRECTORY,
|
||||
fs: { existsSync: () => true },
|
||||
platform: 'win32',
|
||||
spawn: () => { throw new Error('spawn failed'); }
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => manager.launchProxy(validRequest()),
|
||||
(error) => {
|
||||
assert.match(error.message, /spawn failed/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('tracks only safe process metadata and never exposes usernames', () => {
|
||||
const { manager } = createHarness();
|
||||
manager.launchProxy(validRequest());
|
||||
|
||||
const tracked = manager.listTrackedProxies();
|
||||
assert.deepEqual(tracked, [{
|
||||
processId: 4101,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
startedAt: 1_777_777_777_777,
|
||||
status: 'running'
|
||||
}]);
|
||||
assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/);
|
||||
});
|
||||
|
||||
test('stopping one tracked process leaves the other tracked process alive', () => {
|
||||
const first = new FakeChild(4101);
|
||||
const second = new FakeChild(4102);
|
||||
const { manager } = createHarness({ children: [first, second] });
|
||||
manager.launchProxy(validRequest());
|
||||
manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' }));
|
||||
|
||||
const result = manager.stopProxy(4101);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
success: true,
|
||||
processId: 4101,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
status: 'stop-requested'
|
||||
});
|
||||
assert.deepEqual(first.killCalls, ['SIGTERM']);
|
||||
assert.deepEqual(second.killCalls, []);
|
||||
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]);
|
||||
});
|
||||
|
||||
test('never stops an untracked PID', () => {
|
||||
const child = new FakeChild(4101);
|
||||
const { manager } = createHarness({ children: [child] });
|
||||
manager.launchProxy(validRequest());
|
||||
|
||||
assert.deepEqual(manager.stopProxy(9999), {
|
||||
success: false,
|
||||
processId: 9999,
|
||||
status: 'not-tracked'
|
||||
});
|
||||
assert.deepEqual(child.killCalls, []);
|
||||
});
|
||||
|
||||
test('reports already-exited and permission-denied states honestly', () => {
|
||||
const exited = new FakeChild(4101, false);
|
||||
const denied = new FakeChild(4102);
|
||||
denied.killError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
||||
const { manager } = createHarness({ children: [exited, denied] });
|
||||
manager.launchProxy(validRequest());
|
||||
manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' }));
|
||||
|
||||
assert.deepEqual(manager.stopProxy(4101), {
|
||||
success: true,
|
||||
processId: 4101,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
status: 'already-exited'
|
||||
});
|
||||
assert.deepEqual(manager.stopProxy(4102), {
|
||||
success: false,
|
||||
processId: 4102,
|
||||
deviceId: '123e4567-e89b-42d3-a456-426614174001',
|
||||
status: 'permission-denied',
|
||||
message: 'Unable to stop the tracked proxy process: permission denied.'
|
||||
});
|
||||
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]);
|
||||
});
|
||||
|
||||
test('exit events remove only the matching owned child', () => {
|
||||
const original = new FakeChild(4101);
|
||||
const replacement = new FakeChild(4101);
|
||||
const { manager } = createHarness({ children: [original, replacement] });
|
||||
manager.launchProxy(validRequest());
|
||||
original.emit('exit', 0);
|
||||
manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' }));
|
||||
|
||||
original.emit('exit', 0);
|
||||
|
||||
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]);
|
||||
});
|
||||
|
||||
test('source contains no bearer argv, shell launchers, broad process killers, or persistence APIs', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /cookie|bearer|token|-k/i);
|
||||
assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i);
|
||||
assert.doesNotMatch(source, /\.(?:bat|command)\b/i);
|
||||
assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/);
|
||||
assert.match(source, /shell:\s*false/);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRendererController } = require('../renderer-controller');
|
||||
|
||||
function harness(result) {
|
||||
const calls = [];
|
||||
const controller = createRendererController({
|
||||
disconnect: async () => result,
|
||||
renderConnectionState: (state) => calls.push(['render', state]),
|
||||
clearDisconnectedState: () => calls.push(['clear']),
|
||||
showConnectionStatus: (message, type) => calls.push(['status', message, type]),
|
||||
});
|
||||
return { controller, calls };
|
||||
}
|
||||
|
||||
test('failed disconnect retains visible connection, device, and proxy state', async () => {
|
||||
const result = {
|
||||
success: false,
|
||||
connected: true,
|
||||
activeProxies: [{ deviceId: '550e8400-e29b-41d4-a716-446655440000', processId: 4101 }],
|
||||
message: 'Could not stop every active proxy. The Alta session remains connected.',
|
||||
};
|
||||
const { controller, calls } = harness(result);
|
||||
|
||||
assert.equal(await controller.disconnect(), result);
|
||||
assert.deepEqual(calls, [['status', result.message, 'error']]);
|
||||
});
|
||||
|
||||
test('successful disconnect renders disconnected state and clears device state', async () => {
|
||||
const result = { success: true, connected: false, origin: null, activeProxies: [] };
|
||||
const { controller, calls } = harness(result);
|
||||
|
||||
assert.equal(await controller.disconnect(), result);
|
||||
assert.deepEqual(calls, [
|
||||
['render', result],
|
||||
['clear'],
|
||||
['status', 'Disconnected from Alta.', 'info'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
'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,
|
||||
computeCookieProof,
|
||||
createRequestLimiter,
|
||||
} = 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, url = '/cookie', body = '{}' } = {}) {
|
||||
const request = new PassThrough();
|
||||
request.method = method;
|
||||
request.url = url;
|
||||
request.headers = { origin };
|
||||
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 tracked = [];
|
||||
const proxyManager = {
|
||||
launchProxy(request) {
|
||||
calls.push(request);
|
||||
tracked.push({ processId: 91, deviceId: request.deviceId, status: 'running', startedAt: 1 });
|
||||
return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' };
|
||||
},
|
||||
stopProxy(processId) {
|
||||
calls.push({ processId });
|
||||
tracked.splice(0, tracked.length);
|
||||
return { success: true, processId, status: 'stop-requested' };
|
||||
},
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
};
|
||||
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',
|
||||
'proxy.operator@example.com'
|
||||
);
|
||||
assert.equal(launched.success, true);
|
||||
assert.deepEqual(calls[0], {
|
||||
deploymentHost: 'customer.avasecurity.com',
|
||||
username: 'proxy.operator@example.com',
|
||||
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('runtime rejects invalid usernames before calling the proxy manager', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
|
||||
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
let launches = 0;
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: { getDevices: async () => [{ guid: deviceId }] },
|
||||
proxyManager: {
|
||||
launchProxy() { launches += 1; },
|
||||
listTrackedProxies() { return []; },
|
||||
},
|
||||
});
|
||||
await runtime.getDevices();
|
||||
|
||||
for (const username of ['', 'x'.repeat(255), 'operator@example.com\r\n-k secret', null]) {
|
||||
const result = await runtime.launchProxy(deviceId, username);
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.message, /username/i);
|
||||
}
|
||||
assert.equal(launches, 0);
|
||||
});
|
||||
|
||||
test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => {
|
||||
const protect = (value) => Buffer.from(`protected:${value}`);
|
||||
const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length);
|
||||
const auth = new BridgeAuth({ protect, unprotect });
|
||||
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 allowedPreflight = requestHarness({ method: 'OPTIONS' });
|
||||
const allowedResponse = responseHarness();
|
||||
await handler(allowedPreflight, allowedResponse);
|
||||
assert.equal(allowedResponse.statusCode, 204);
|
||||
assert.equal(allowedResponse.headers['access-control-allow-origin'], APT_EXTENSION_ORIGIN);
|
||||
assert.doesNotMatch(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/i);
|
||||
|
||||
const clientNonce = Buffer.alloc(32, 3).toString('base64url');
|
||||
const challengeResponse = responseHarness();
|
||||
await handler(requestHarness({ url: '/challenge', body: JSON.stringify({ clientNonce }) }), challengeResponse);
|
||||
assert.equal(challengeResponse.statusCode, 200);
|
||||
const challenge = JSON.parse(challengeResponse.body);
|
||||
assert.equal(JSON.stringify(challenge).includes(secret), false);
|
||||
|
||||
const cookieBody = {
|
||||
clientNonce,
|
||||
serverNonce: challenge.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'valid-cookie',
|
||||
};
|
||||
cookieBody.proof = computeCookieProof(secret, cookieBody);
|
||||
const acceptedResponse = responseHarness();
|
||||
await handler(requestHarness({ body: JSON.stringify(cookieBody) }), 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);
|
||||
|
||||
const replayResponse = responseHarness();
|
||||
await handler(requestHarness({ body: JSON.stringify(cookieBody) }), replayResponse);
|
||||
assert.equal(replayResponse.statusCode, 403);
|
||||
});
|
||||
|
||||
test('bridge limiter encloses body reads and HMAC authentication under forged floods', async () => {
|
||||
const auth = new BridgeAuth({ protect: (value) => Buffer.from(value), unprotect: (value) => Buffer.from(value).toString() });
|
||||
auth.rotate();
|
||||
const limiter = createRequestLimiter({ maxConcurrent: 1 });
|
||||
const handler = createBridgeHandler({ bridgeAuth: auth, sessionStore: createSessionStore(), limiter, deadlineMs: 50 });
|
||||
const held = new PassThrough();
|
||||
held.method = 'POST';
|
||||
held.url = '/challenge';
|
||||
held.headers = { origin: APT_EXTENSION_ORIGIN };
|
||||
const first = handler(held, responseHarness());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(limiter.active, 1);
|
||||
const rejected = responseHarness();
|
||||
await handler(requestHarness({ url: '/challenge', body: JSON.stringify({ clientNonce: 'A'.repeat(43) }) }), rejected);
|
||||
assert.equal(rejected.statusCode, 429);
|
||||
held.end('{}');
|
||||
await first;
|
||||
});
|
||||
|
||||
test('pairing envelope persists encrypted 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 protect = (value) => Buffer.from(`dpapi:${value}`);
|
||||
const unprotect = (value) => Buffer.from(value).toString().slice('dpapi:'.length);
|
||||
const controller = new PairingController({ envelopePath, protect, unprotect });
|
||||
|
||||
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, { protect, unprotect });
|
||||
assert.equal(Object.values(persisted).includes(first.secret), false);
|
||||
assert.equal(fs.readFileSync(envelopePath, 'utf8').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('pairing fails closed and reports unavailable without secure storage', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-unavailable-'));
|
||||
const controller = new PairingController({ envelopePath: path.join(directory, 'bridge-pairing.json') });
|
||||
assert.deepEqual(controller.initialize(), { paired: false, unavailable: true });
|
||||
assert.deepEqual(controller.getStatus(), { paired: false, unavailable: true });
|
||||
assert.throws(() => controller.rotate(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('runtime reconciles exited children and permits relaunch for the same device', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const tracked = [];
|
||||
let nextPid = 4101;
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: { getDevices: async () => [{ guid: deviceId }] },
|
||||
proxyManager: {
|
||||
launchProxy() {
|
||||
const entry = { processId: nextPid++, deviceId, status: 'running', startedAt: 1 };
|
||||
tracked.push(entry);
|
||||
return { success: true, ...entry };
|
||||
},
|
||||
stopProxy() { throw new Error('unused'); },
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
},
|
||||
});
|
||||
await runtime.getDevices();
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4101);
|
||||
tracked.length = 0;
|
||||
assert.deepEqual(runtime.getConnectionState().activeProxies, []);
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4102);
|
||||
});
|
||||
|
||||
test('disconnect stops every owned proxy before clearing the Alta session', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const tracked = [
|
||||
{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 },
|
||||
{ processId: 4102, deviceId: '550e8400-e29b-41d4-a716-446655440001', status: 'running', startedAt: 1 },
|
||||
];
|
||||
const stopped = [];
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {},
|
||||
proxyManager: {
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
stopProxy(processId) {
|
||||
stopped.push(processId);
|
||||
tracked.splice(tracked.findIndex((entry) => entry.processId === processId), 1);
|
||||
return { success: true, processId, status: 'stop-requested' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = runtime.disconnect();
|
||||
assert.deepEqual(stopped, [4101, 4102]);
|
||||
assert.equal(state.connected, false);
|
||||
assert.deepEqual(state.activeProxies, []);
|
||||
});
|
||||
|
||||
test('disconnect reports failure truthfully and retains session when an owned proxy cannot stop', () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const tracked = [{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 }];
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {},
|
||||
proxyManager: {
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
stopProxy() { return { success: false, processId: 4101, status: 'permission-denied' }; },
|
||||
},
|
||||
});
|
||||
const state = runtime.disconnect();
|
||||
assert.equal(state.success, false);
|
||||
assert.equal(state.connected, true);
|
||||
assert.deepEqual(state.activeProxies.map((entry) => entry.processId), [4101]);
|
||||
});
|
||||
|
||||
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.match(preload, /launchProxy:\s*\(deviceId, username\)/);
|
||||
assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i);
|
||||
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
|
||||
assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/);
|
||||
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
|
||||
assert.match(html, /id="altaUsername"/);
|
||||
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/);
|
||||
});
|
||||
|
||||
test('production source and documentation are GitPeji-only with no active GitHub workflow', () => {
|
||||
assert.equal(fs.existsSync(path.join(ROOT, '.github', 'workflows', 'deploy-pages.yml')), false);
|
||||
const sourceAndDocs = [
|
||||
'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'index.html',
|
||||
'src/electron-runtime.js', 'src/proxy-launch.js', 'README.md', 'CLAUDE.md',
|
||||
'docs/security/2026-08-security-baseline.md',
|
||||
'docs/plans/2026-08-19-apt-security-foundation.md',
|
||||
].map(read).join('\n');
|
||||
assert.doesNotMatch(sourceAndDocs, /github/i);
|
||||
assert.match(sourceAndDocs, /GitPeji/);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { SessionStore } = require('../src/session-store');
|
||||
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
|
||||
test('stores a canonical origin and cookie only in main-process memory', () => {
|
||||
const store = new SessionStore();
|
||||
store.establish('https://Tenant.AVASECURITY.com/', SENTINEL);
|
||||
|
||||
assert.deepEqual(store.describe(), {
|
||||
connected: true,
|
||||
origin: 'https://tenant.avasecurity.com',
|
||||
});
|
||||
assert.equal(JSON.stringify(store.describe()).includes(SENTINEL), false);
|
||||
|
||||
const session = store.requireSession();
|
||||
assert.equal(session.origin, 'https://tenant.avasecurity.com');
|
||||
assert.equal(session.cookie, SENTINEL);
|
||||
assert.equal(Object.isFrozen(session), true);
|
||||
});
|
||||
|
||||
test('rejects malformed and header-injecting cookies', () => {
|
||||
const store = new SessionStore();
|
||||
for (const cookie of ['', null, 12, 'secret\r\nX-Evil: yes', `x${'a'.repeat(4096)}`, 'abc\0def', 'token; injected=yes']) {
|
||||
assert.throws(() => store.establish('https://tenant.avasecurity.com', cookie), {
|
||||
code: 'INVALID_SESSION_COOKIE',
|
||||
});
|
||||
}
|
||||
assert.equal(store.describe().connected, false);
|
||||
});
|
||||
|
||||
test('does not replace a valid session when a new session is invalid', () => {
|
||||
const store = new SessionStore();
|
||||
store.establish('https://one.avasecurity.com', SENTINEL);
|
||||
assert.throws(() => store.establish('https://evil.example', 'replacement'));
|
||||
assert.equal(store.requireSession().origin, 'https://one.avasecurity.com');
|
||||
assert.equal(store.requireSession().cookie, SENTINEL);
|
||||
});
|
||||
|
||||
test('clear removes session references and dispose permanently closes the store', () => {
|
||||
const store = new SessionStore();
|
||||
store.establish('https://tenant.avigilon.com', SENTINEL);
|
||||
store.clear();
|
||||
|
||||
assert.deepEqual(store.describe(), { connected: false, origin: null });
|
||||
assert.throws(() => store.requireSession(), { code: 'NO_ALTA_SESSION' });
|
||||
|
||||
store.establish('https://tenant.avigilon.com', 'new-token');
|
||||
store.dispose();
|
||||
assert.deepEqual(store.describe(), { connected: false, origin: null });
|
||||
assert.throws(() => store.establish('https://tenant.avigilon.com', 'again'), {
|
||||
code: 'SESSION_STORE_DISPOSED',
|
||||
});
|
||||
assert.doesNotThrow(() => store.dispose());
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
LATEST_RELEASE_URL,
|
||||
RELEASES_PAGE_URL,
|
||||
MAX_BODY_BYTES,
|
||||
checkForUpdate,
|
||||
compareSemver,
|
||||
defaultRequest,
|
||||
} = require('../src/update-policy');
|
||||
|
||||
function response(body, overrides = {}) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
body: Buffer.from(body),
|
||||
url: LATEST_RELEASE_URL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, (error) => {
|
||||
assert.equal(error.code, code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test('accepts a live-shape GitPeji v-tag and normalizes it to bare semver', async () => {
|
||||
const fixture = fs.readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'gitpeji-latest-release.json'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await checkForUpdate({
|
||||
currentVersion: '0.9.0',
|
||||
request: async () => response(fixture),
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.equal(result.latestVersion, '1.0.0');
|
||||
assert.equal(result.releaseName, 'Alta Proxy Tool v1.0.0');
|
||||
});
|
||||
|
||||
test('valid update returns only sanitized, check-only metadata', async () => {
|
||||
let requestOptions;
|
||||
const request = async (options) => {
|
||||
requestOptions = options;
|
||||
return response(JSON.stringify({
|
||||
id: 42,
|
||||
tag_name: '1.2.3',
|
||||
name: ' Security <Release> ',
|
||||
published_at: '2026-08-19T12:34:56Z',
|
||||
body: 'untrusted release notes',
|
||||
html_url: 'https://evil.example/download',
|
||||
assets: [{ browser_download_url: 'https://evil.example/payload.exe' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const result = await checkForUpdate({
|
||||
currentVersion: '1.2.2',
|
||||
request,
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
});
|
||||
|
||||
assert.deepEqual(requestOptions, {
|
||||
url: LATEST_RELEASE_URL,
|
||||
timeoutMs: 5000,
|
||||
maxBodyBytes: MAX_BODY_BYTES,
|
||||
redirects: 'error',
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
status: 'update-available',
|
||||
currentVersion: '1.2.2',
|
||||
latestVersion: '1.2.3',
|
||||
releaseName: 'Security <Release>',
|
||||
publishedAt: '2026-08-19T12:34:56.000Z',
|
||||
releasesPageUrl: RELEASES_PAGE_URL,
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('payload.exe'), false);
|
||||
assert.equal(JSON.stringify(result).includes('release notes'), false);
|
||||
});
|
||||
|
||||
test('equal or older release is reported as up to date', async () => {
|
||||
const request = async () => response(JSON.stringify({
|
||||
tag_name: '2.0.0',
|
||||
name: 'Current',
|
||||
published_at: '2026-08-19T12:34:56Z',
|
||||
}));
|
||||
|
||||
const result = await checkForUpdate({ currentVersion: '2.0.0', request });
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.equal(result.releasesPageUrl, RELEASES_PAGE_URL);
|
||||
});
|
||||
|
||||
test('404 means there is no published release', async () => {
|
||||
const request = async () => response('', { statusCode: 404 });
|
||||
const result = await checkForUpdate({ currentVersion: '1.0.0', request });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: 'no-release',
|
||||
currentVersion: '1.0.0',
|
||||
releasesPageUrl: RELEASES_PAGE_URL,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed JSON and invalid response schema fail closed', async () => {
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({ currentVersion: '1.0.0', request: async () => response('{') }),
|
||||
'INVALID_RESPONSE',
|
||||
);
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({ currentVersion: '1.0.0', request: async () => response('[]') }),
|
||||
'INVALID_RESPONSE',
|
||||
);
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response(JSON.stringify({ tag_name: '1.1.0', name: 7 })),
|
||||
}),
|
||||
'INVALID_RESPONSE',
|
||||
);
|
||||
});
|
||||
|
||||
test('oversized bodies fail before parsing', async () => {
|
||||
const oversized = Buffer.alloc(MAX_BODY_BYTES + 1, 0x20);
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response('', { body: oversized }),
|
||||
}),
|
||||
'RESPONSE_TOO_LARGE',
|
||||
);
|
||||
});
|
||||
|
||||
test('redirects and response host drift are rejected', async () => {
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response('', {
|
||||
statusCode: 302,
|
||||
headers: { location: 'https://evil.example/latest' },
|
||||
}),
|
||||
}),
|
||||
'REDIRECT_REJECTED',
|
||||
);
|
||||
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response('{}', { url: 'https://evil.example/latest' }),
|
||||
}),
|
||||
'UNTRUSTED_RESPONSE_URL',
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid or non-strict semver fails closed', async () => {
|
||||
const invalidVersions = [
|
||||
'V1.2.3',
|
||||
'vv1.2.3',
|
||||
' v1.2.3',
|
||||
'v1.2.3 ',
|
||||
'v1.2',
|
||||
'v01.2.3',
|
||||
'v1.2.3.4',
|
||||
'vlatest',
|
||||
'1.2',
|
||||
'01.2.3',
|
||||
'1.2.3.4',
|
||||
'latest',
|
||||
];
|
||||
|
||||
for (const tag_name of invalidVersions) {
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response(JSON.stringify({ tag_name })),
|
||||
}),
|
||||
'INVALID_RELEASE_VERSION',
|
||||
);
|
||||
}
|
||||
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({ currentVersion: 'v1.0.0', request: async () => response('{}') }),
|
||||
'INVALID_CURRENT_VERSION',
|
||||
);
|
||||
});
|
||||
|
||||
test('strict semver comparison handles prerelease precedence', () => {
|
||||
assert.equal(compareSemver('1.0.0', '1.0.0'), 0);
|
||||
assert.equal(compareSemver('1.0.1', '1.0.0'), 1);
|
||||
assert.equal(compareSemver('1.0.0-alpha.2', '1.0.0-alpha.10'), -1);
|
||||
assert.equal(compareSemver('1.0.0-rc.1', '1.0.0'), -1);
|
||||
assert.equal(compareSemver('2.0.0+build.1', '2.0.0+build.2'), 0);
|
||||
});
|
||||
|
||||
test('unexpected HTTP and content types fail closed', async () => {
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response('server error', { statusCode: 500 }),
|
||||
}),
|
||||
'HTTP_ERROR',
|
||||
);
|
||||
await rejectsWithCode(
|
||||
checkForUpdate({
|
||||
currentVersion: '1.0.0',
|
||||
request: async () => response('{}', { headers: { 'content-type': 'text/html' } }),
|
||||
}),
|
||||
'INVALID_CONTENT_TYPE',
|
||||
);
|
||||
});
|
||||
|
||||
test('default transport enforces an absolute deadline despite trickled response bytes', async () => {
|
||||
const request = new EventEmitter();
|
||||
const responseStream = new EventEmitter();
|
||||
request.destroyedWith = null;
|
||||
responseStream.destroyed = false;
|
||||
request.destroy = (error) => { request.destroyedWith = error; request.emit('error', error); };
|
||||
request.setTimeout = () => { throw new Error('inactivity timeout must not be used'); };
|
||||
responseStream.destroy = () => { responseStream.destroyed = true; };
|
||||
responseStream.headers = { 'content-type': 'application/json' };
|
||||
responseStream.statusCode = 200;
|
||||
let deadline;
|
||||
let cleared = false;
|
||||
const pending = defaultRequest({
|
||||
url: LATEST_RELEASE_URL,
|
||||
timeoutMs: 5000,
|
||||
maxBodyBytes: MAX_BODY_BYTES,
|
||||
httpsGet: (_url, _options, onResponse) => {
|
||||
onResponse(responseStream);
|
||||
return request;
|
||||
},
|
||||
setTimer: (callback, milliseconds) => { assert.equal(milliseconds, 5000); deadline = callback; return 7; },
|
||||
clearTimer: (timer) => { assert.equal(timer, 7); cleared = true; },
|
||||
});
|
||||
responseStream.emit('data', Buffer.from('{'));
|
||||
responseStream.emit('data', Buffer.from(' '));
|
||||
deadline();
|
||||
await rejectsWithCode(pending, 'REQUEST_TIMEOUT');
|
||||
assert.equal(responseStream.destroyed, true);
|
||||
assert.equal(request.destroyedWith.code, 'REQUEST_TIMEOUT');
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
canonicalizeAltaOrigin,
|
||||
isAltaOrigin,
|
||||
} = require('../src/url-policy');
|
||||
|
||||
test('accepts and canonicalizes HTTPS Alta deployment subdomains', () => {
|
||||
assert.equal(canonicalizeAltaOrigin('https://Example.AVASECURITY.com/'), 'https://example.avasecurity.com');
|
||||
assert.equal(canonicalizeAltaOrigin('https://edge.eu.avigilon.com:443'), 'https://edge.eu.avigilon.com');
|
||||
assert.equal(isAltaOrigin('https://tenant.avasecurity.com'), true);
|
||||
});
|
||||
|
||||
test('rejects roots, lookalikes, HTTP, and arbitrary exfiltration destinations', () => {
|
||||
for (const value of [
|
||||
'https://avasecurity.com',
|
||||
'https://avigilon.com',
|
||||
'https://avasecurity.com.evil.example',
|
||||
'https://tenant.avasecurity.com.evil.example',
|
||||
'https://evilavasecurity.com',
|
||||
'http://tenant.avasecurity.com',
|
||||
'https://example.com',
|
||||
'file:///etc/passwd',
|
||||
]) {
|
||||
assert.throws(() => canonicalizeAltaOrigin(value), { code: 'INVALID_ALTA_ORIGIN' }, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects authority tricks, fragments, paths, queries and non-default ports', () => {
|
||||
for (const value of [
|
||||
'https://user:pass@tenant.avasecurity.com',
|
||||
'https://tenant.avasecurity.com/#fragment',
|
||||
'https://tenant.avasecurity.com/api/v1/devices',
|
||||
'https://tenant.avasecurity.com?next=https://evil.example',
|
||||
'https://tenant.avasecurity.com:8443',
|
||||
'https://tenant.avasecurity.com\\@evil.example',
|
||||
]) {
|
||||
assert.throws(() => canonicalizeAltaOrigin(value), { code: 'INVALID_ALTA_ORIGIN' }, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects CRLF, whitespace, non-strings, oversized and malformed values', () => {
|
||||
for (const value of [
|
||||
'https://tenant.avasecurity.com\r\nX-Test: injected',
|
||||
' https://tenant.avasecurity.com',
|
||||
'https://tenant.avasecurity.com ',
|
||||
'not a URL',
|
||||
'',
|
||||
null,
|
||||
7,
|
||||
`https://${'a'.repeat(513)}.avasecurity.com`,
|
||||
]) {
|
||||
assert.throws(() => canonicalizeAltaOrigin(value), { code: 'INVALID_ALTA_ORIGIN' }, String(value));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user