fix: integrate hardened APT security boundary
This commit is contained in:
+1
-1
@@ -133,7 +133,7 @@ class BridgeAuth {
|
||||
authenticateRequest(request) {
|
||||
if (!request || typeof request !== 'object') return false;
|
||||
const headers = request.headers || {};
|
||||
const secret = headers['x-apt-pairing-secret'];
|
||||
const secret = headers['x-apt-pairing'];
|
||||
return this.authenticate({ origin: headers.origin, secret });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
APT_EXTENSION_ORIGIN,
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
createRequestLimiter,
|
||||
readJsonBody,
|
||||
} = require('./bridge-auth');
|
||||
const { RELEASES_PAGE_URL } = require('./update-policy');
|
||||
const { validateDeviceId } = require('./proxy-launch');
|
||||
|
||||
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
|
||||
|
||||
function safeErrorMessage(error, fallback) {
|
||||
const allowed = new Set([
|
||||
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
|
||||
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
|
||||
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'SPAWN_FAILED',
|
||||
]);
|
||||
return error && allowed.has(error.code) && typeof error.message === 'string'
|
||||
? error.message
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function loadPairingEnvelope(envelopePath) {
|
||||
if (!fs.existsSync(envelopePath)) return null;
|
||||
const bytes = fs.readFileSync(envelopePath);
|
||||
if (bytes.length === 0 || bytes.length > 4096) throw new Error('Invalid pairing envelope file');
|
||||
const parsed = JSON.parse(bytes.toString('utf8'));
|
||||
// BridgeAuth performs the authoritative envelope schema validation.
|
||||
return new BridgeAuth({ envelope: parsed }).envelope;
|
||||
}
|
||||
|
||||
function atomicWritePairingEnvelope(envelopePath, envelope) {
|
||||
const directory = path.dirname(envelopePath);
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const temporaryPath = `${envelopePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
const payload = `${JSON.stringify(envelope)}\n`;
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
fs.renameSync(temporaryPath, envelopePath);
|
||||
fs.chmodSync(envelopePath, 0o600);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(temporaryPath); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class PairingController {
|
||||
constructor({ envelopePath, bridgeAuth } = {}) {
|
||||
if (typeof envelopePath !== 'string' || envelopePath.length === 0) {
|
||||
throw new TypeError('PairingController requires an envelope path');
|
||||
}
|
||||
this.envelopePath = envelopePath;
|
||||
if (bridgeAuth) {
|
||||
this.bridgeAuth = bridgeAuth;
|
||||
} else {
|
||||
let envelope = null;
|
||||
try {
|
||||
envelope = loadPairingEnvelope(envelopePath);
|
||||
} catch {
|
||||
try { fs.unlinkSync(envelopePath); } catch {}
|
||||
}
|
||||
this.bridgeAuth = new BridgeAuth({ envelope });
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.bridgeAuth.envelope) return { paired: true };
|
||||
return this.rotate();
|
||||
}
|
||||
|
||||
rotate() {
|
||||
const { secret, envelope } = this.bridgeAuth.rotate();
|
||||
try {
|
||||
atomicWritePairingEnvelope(this.envelopePath, envelope);
|
||||
} catch (error) {
|
||||
this.bridgeAuth.revoke();
|
||||
throw error;
|
||||
}
|
||||
return { paired: true, secret };
|
||||
}
|
||||
|
||||
revoke() {
|
||||
this.bridgeAuth.revoke();
|
||||
try {
|
||||
fs.unlinkSync(this.envelopePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return { paired: false };
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return { paired: Boolean(this.bridgeAuth.envelope) };
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(response, statusCode, payload) {
|
||||
if (response.writableEnded) return;
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function createBridgeHandler({
|
||||
bridgeAuth,
|
||||
sessionStore,
|
||||
onConnectionStateChanged = () => {},
|
||||
limiter = createRequestLimiter({ maxConcurrent: 4 }),
|
||||
maxBodyBytes,
|
||||
deadlineMs,
|
||||
} = {}) {
|
||||
if (!bridgeAuth || typeof bridgeAuth.authenticateRequest !== 'function') {
|
||||
throw new TypeError('Bridge handler requires bridge authentication');
|
||||
}
|
||||
if (!sessionStore || typeof sessionStore.establish !== 'function') {
|
||||
throw new TypeError('Bridge handler requires a session store');
|
||||
}
|
||||
|
||||
return async function handleBridgeRequest(request, response) {
|
||||
const origin = request && request.headers && request.headers.origin;
|
||||
if (origin !== APT_EXTENSION_ORIGIN) {
|
||||
writeJson(response, 403, { success: false, message: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader('Access-Control-Allow-Origin', APT_EXTENSION_ORIGIN);
|
||||
response.setHeader('Vary', 'Origin');
|
||||
response.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Pairing');
|
||||
response.setHeader('Access-Control-Max-Age', '600');
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204, { 'Cache-Control': 'no-store' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'POST' || request.url !== '/cookie') {
|
||||
writeJson(response, 404, { success: false, message: 'Not found' });
|
||||
return;
|
||||
}
|
||||
if (!bridgeAuth.authenticateRequest(request)) {
|
||||
writeJson(response, 403, { success: false, message: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await limiter.run(async () => {
|
||||
const data = await readJsonBody(request, { maxBytes: maxBodyBytes, deadlineMs });
|
||||
const state = sessionStore.establish(data.deploymentUrl, data.cookieValue);
|
||||
onConnectionStateChanged({ connected: state.connected, origin: state.origin });
|
||||
});
|
||||
writeJson(response, 200, { success: true, message: 'Session received' });
|
||||
} catch (error) {
|
||||
const statusCode = error instanceof BridgeAuthError
|
||||
? error.statusCode
|
||||
: error && error.code === 'INVALID_SESSION_COOKIE'
|
||||
? 400
|
||||
: 400;
|
||||
writeJson(response, statusCode, { success: false, message: statusCode === 429 ? 'Too many requests' : 'Invalid request' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class AppRuntime {
|
||||
constructor({
|
||||
sessionStore,
|
||||
altaClient,
|
||||
proxyManager,
|
||||
checkForUpdate,
|
||||
currentVersion,
|
||||
openExternal,
|
||||
} = {}) {
|
||||
this.sessionStore = sessionStore;
|
||||
this.altaClient = altaClient;
|
||||
this.proxyManager = proxyManager;
|
||||
this.checkForUpdatePolicy = checkForUpdate;
|
||||
this.currentVersion = currentVersion;
|
||||
this.openExternal = openExternal;
|
||||
this.allowedDeviceIds = new Set();
|
||||
this.proxyByDevice = new Map();
|
||||
}
|
||||
|
||||
async getDevices() {
|
||||
try {
|
||||
const devices = await this.altaClient.getDevices();
|
||||
this.allowedDeviceIds = new Set();
|
||||
for (const device of devices) {
|
||||
const candidate = device && (device.guid || device.id);
|
||||
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {}
|
||||
}
|
||||
return { success: true, devices };
|
||||
} catch (error) {
|
||||
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceSites() {
|
||||
try {
|
||||
return { success: true, sites: await this.altaClient.getDeviceSites() };
|
||||
} catch (error) {
|
||||
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthInfo() {
|
||||
try {
|
||||
return { success: true, authInfo: await this.altaClient.getAuthInfo() };
|
||||
} catch (error) {
|
||||
return { success: false, message: safeErrorMessage(error, 'Failed to get authentication information') };
|
||||
}
|
||||
}
|
||||
|
||||
async launchProxy(deviceId) {
|
||||
try {
|
||||
const validatedId = validateDeviceId(deviceId);
|
||||
if (!this.allowedDeviceIds.has(validatedId)) {
|
||||
return { success: false, message: 'Select a device from the current Alta device list.' };
|
||||
}
|
||||
if (this.proxyByDevice.has(validatedId)) {
|
||||
return { success: false, message: 'A proxy is already running for this device.' };
|
||||
}
|
||||
const session = this.sessionStore.requireSession();
|
||||
const result = this.proxyManager.launchProxy({
|
||||
deploymentHost: new URL(session.origin).hostname,
|
||||
cookie: session.cookie,
|
||||
deviceId: validatedId,
|
||||
});
|
||||
this.proxyByDevice.set(validatedId, result.processId);
|
||||
return { success: true, processId: result.processId, deviceId: validatedId, status: result.status };
|
||||
} catch (error) {
|
||||
return { success: false, message: safeErrorMessage(error, 'Failed to launch proxy') };
|
||||
}
|
||||
}
|
||||
|
||||
async stopProxy(key) {
|
||||
let deviceId = null;
|
||||
let processId = null;
|
||||
if (typeof key === 'string') {
|
||||
try { deviceId = validateDeviceId(key); } catch { return { success: false, message: 'Invalid proxy key.' }; }
|
||||
processId = this.proxyByDevice.get(deviceId);
|
||||
} else if (Number.isSafeInteger(key) && key > 0) {
|
||||
processId = key;
|
||||
for (const [candidateDeviceId, candidateProcessId] of this.proxyByDevice) {
|
||||
if (candidateProcessId === processId) deviceId = candidateDeviceId;
|
||||
}
|
||||
}
|
||||
if (!processId || !deviceId) return { success: false, message: 'Proxy process is not owned by this app.' };
|
||||
|
||||
const result = this.proxyManager.stopProxy(processId);
|
||||
if (result.success) this.proxyByDevice.delete(deviceId);
|
||||
return { ...result, deviceId };
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.sessionStore.clear();
|
||||
this.allowedDeviceIds.clear();
|
||||
return this.getConnectionState();
|
||||
}
|
||||
|
||||
getConnectionState() {
|
||||
const state = this.sessionStore.describe();
|
||||
return {
|
||||
connected: state.connected,
|
||||
origin: state.origin,
|
||||
activeProxies: Array.from(this.proxyByDevice, ([deviceId, processId]) => ({ deviceId, processId })),
|
||||
};
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
const result = await this.checkForUpdatePolicy({ currentVersion: this.currentVersion });
|
||||
return { success: true, ...result };
|
||||
} catch {
|
||||
return { success: false, message: 'Could not securely check for updates.' };
|
||||
}
|
||||
}
|
||||
|
||||
async openFixedReleasesPage() {
|
||||
await this.openExternal(RELEASES_PAGE_URL);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppRuntime,
|
||||
PAIRING_ENVELOPE_FILENAME,
|
||||
PairingController,
|
||||
atomicWritePairingEnvelope,
|
||||
createBridgeHandler,
|
||||
loadPairingEnvelope,
|
||||
};
|
||||
Reference in New Issue
Block a user