mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
fix(security): harden extension and engine trust
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: b08c76517a...528810a190
@@ -0,0 +1,45 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
export function collectRegularFiles(root, options = {}) {
|
||||
const ignoredNames = new Set(options.ignoredNames || []);
|
||||
const files = [];
|
||||
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
if (ignoredNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Unsupported symlink in engine payload: ${relative}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
walk(file);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(file);
|
||||
} else {
|
||||
throw new Error(`Unsupported filesystem entry in engine payload: ${relative}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return files.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function treeDigest(root) {
|
||||
const files = collectRegularFiles(root);
|
||||
const digest = crypto.createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
digest.update(`${relative}\0${sha256(file)}\n`);
|
||||
}
|
||||
return { files: files.length, sha256: digest.digest('hex') };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { collectRegularFiles, treeDigest } from './engine-payload-integrity.js';
|
||||
|
||||
test('collectRegularFiles returns regular files and honors ignored names', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'nested'));
|
||||
fs.writeFileSync(path.join(root, 'engine'), 'binary');
|
||||
fs.writeFileSync(path.join(root, 'nested', 'runtime.dat'), 'runtime');
|
||||
fs.writeFileSync(path.join(root, 'payload-manifest.json'), '{}');
|
||||
|
||||
const files = collectRegularFiles(root, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
}).map(file => path.relative(root, file).split(path.sep).join('/'));
|
||||
|
||||
assert.deepEqual(files, ['engine', 'nested/runtime.dat']);
|
||||
const digest = treeDigest(path.join(root, 'nested'));
|
||||
assert.equal(digest.files, 1);
|
||||
assert.match(digest.sha256, /^[a-f0-9]{64}$/);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collectRegularFiles rejects symlinks in engine payloads', { skip: process.platform === 'win32' }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'target'), 'target');
|
||||
fs.symlinkSync('target', path.join(root, 'link'));
|
||||
|
||||
assert.throws(
|
||||
() => collectRegularFiles(root),
|
||||
/Unsupported symlink in engine payload: link/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
@@ -36,10 +36,6 @@ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${targ
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
async function download(name, source) {
|
||||
const sourcePath = new URL(source.url).pathname;
|
||||
const archive = path.join(
|
||||
@@ -99,16 +95,9 @@ function copyExecutable(source, engine) {
|
||||
}
|
||||
|
||||
function writePayloadManifest() {
|
||||
const files = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile() && entry.name !== 'payload-manifest.json') files.push(file);
|
||||
}
|
||||
};
|
||||
walk(destination);
|
||||
files.sort((left, right) => left.localeCompare(right));
|
||||
const files = collectRegularFiles(destination, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
});
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
target,
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import crypto from 'node:crypto';
|
||||
import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
@@ -51,29 +51,6 @@ if (!source) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function treeDigest(root) {
|
||||
const files = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile()) files.push(file);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
files.sort((left, right) => left.localeCompare(right));
|
||||
const digest = crypto.createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
digest.update(`${relative}\0${sha256(file)}\n`);
|
||||
}
|
||||
return { files: files.length, sha256: digest.digest('hex') };
|
||||
}
|
||||
|
||||
if (targetLock) {
|
||||
for (const engine of engines) {
|
||||
const name = `${engine}-${target}${suffix}`;
|
||||
@@ -115,17 +92,9 @@ if (targetLock) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const actualFiles = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile() && entry.name !== 'payload-manifest.json') {
|
||||
actualFiles.push(path.relative(source, file).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(source);
|
||||
const actualFiles = collectRegularFiles(source, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
}).map(file => path.relative(source, file).split(path.sep).join('/'));
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
actualFiles.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
|
||||
+31
-17
@@ -90,6 +90,31 @@ function ok(msg) {
|
||||
console.log(`[OK] ${msg}`);
|
||||
}
|
||||
|
||||
function rejectSymlinks(root, label) {
|
||||
if (!fs.existsSync(root)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let symlinkCount = 0;
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
if (entry.isSymbolicLink()) {
|
||||
fail(`Unsupported symlink in ${label}: ${relative}`);
|
||||
symlinkCount += 1;
|
||||
} else if (entry.isDirectory()) {
|
||||
walk(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
if (symlinkCount === 0) {
|
||||
ok(`${label} contains no symlinks`);
|
||||
}
|
||||
}
|
||||
|
||||
function binName(engine) {
|
||||
return `${engine}${suffix}`;
|
||||
}
|
||||
@@ -208,23 +233,7 @@ console.log('\n─── 6. yt-dlp packaging ───');
|
||||
} catch (e) {
|
||||
fail(`Cannot read _internal/: ${e.message}`);
|
||||
}
|
||||
if (entries) {
|
||||
let broken = 0;
|
||||
for (const entry of entries) {
|
||||
const ep = path.join(internalDir, entry);
|
||||
if (fs.lstatSync(ep).isSymbolicLink()) {
|
||||
try {
|
||||
fs.accessSync(ep);
|
||||
} catch {
|
||||
fail(`Broken symlink in _internal/: ${entry}`);
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (broken === 0) {
|
||||
ok('_internal/ symlinks valid');
|
||||
}
|
||||
}
|
||||
rejectSymlinks(internalDir, '_internal/');
|
||||
|
||||
const requiredRuntimeFiles = [
|
||||
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
|
||||
@@ -253,6 +262,11 @@ console.log('\n─── 6. yt-dlp packaging ───');
|
||||
}
|
||||
}
|
||||
|
||||
const aria2LibsDir = path.join(binariesDir, 'aria2-libs');
|
||||
if (fs.existsSync(aria2LibsDir) && fs.statSync(aria2LibsDir).isDirectory()) {
|
||||
rejectSymlinks(aria2LibsDir, 'aria2-libs/');
|
||||
}
|
||||
|
||||
// ───── Check 7, 8 & 9: Engine version self-tests ─────
|
||||
console.log('\n─── 7 & 8 & 9. Engine version self-tests ───');
|
||||
|
||||
|
||||
@@ -27,7 +27,11 @@ const MAX_URL_COUNT: usize = 200;
|
||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||
const SERVER_HEADER: &str = "x-firelink-server";
|
||||
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||
const PROTOCOL_VERSION: &str = "2";
|
||||
const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
|
||||
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||
const PROTOCOL_VERSION: &str = "3";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
||||
@@ -41,6 +45,7 @@ pub struct ServerState {
|
||||
pub pairing_token: SharedExtensionToken,
|
||||
pub frontend_ready: SharedFrontendReady,
|
||||
pub replay_cache: ReplayCache,
|
||||
pub bound_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -76,11 +81,13 @@ pub async fn start_server(
|
||||
server_port: SharedServerPort,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<(), String> {
|
||||
let (port, listener) = bind_extension_listener().await?;
|
||||
let state = ServerState {
|
||||
app_handle,
|
||||
pairing_token,
|
||||
frontend_ready,
|
||||
replay_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
bound_port: port,
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
@@ -98,7 +105,6 @@ pub async fn start_server(
|
||||
.layer(middleware::from_fn(add_server_identity))
|
||||
.with_state(state);
|
||||
|
||||
let (port, listener) = bind_extension_listener().await?;
|
||||
if let Ok(mut current_port) = server_port.write() {
|
||||
*current_port = Some(port);
|
||||
}
|
||||
@@ -156,13 +162,13 @@ async fn ping_handler(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
) -> Result<Response, StatusCode> {
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(v) => v,
|
||||
None => return StatusCode::FORBIDDEN,
|
||||
None => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
let timestamp_str = match headers
|
||||
@@ -170,14 +176,41 @@ async fn ping_handler(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(v) => v,
|
||||
None => return StatusCode::FORBIDDEN,
|
||||
None => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
let nonce = match headers
|
||||
.get(CLIENT_NONCE_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|value| is_valid_client_nonce(value))
|
||||
{
|
||||
Some(v) => v,
|
||||
None => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
if verify_signature(signature, timestamp_str, &body, &state.pairing_token).is_err() {
|
||||
return StatusCode::FORBIDDEN;
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
let proof = sign_server_proof(
|
||||
timestamp_str,
|
||||
nonce,
|
||||
state.bound_port,
|
||||
&state.pairing_token,
|
||||
)
|
||||
.map_err(|_| StatusCode::FORBIDDEN)?;
|
||||
|
||||
let mut response = Response::new(Body::empty());
|
||||
response.headers_mut().insert(
|
||||
SERVER_PROOF_HEADER,
|
||||
HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
SERVER_PORT_HEADER,
|
||||
HeaderValue::from_str(&state.bound_port.to_string())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn download_handler(
|
||||
@@ -327,6 +360,39 @@ fn verify_signature(
|
||||
Ok(timestamp)
|
||||
}
|
||||
|
||||
fn is_valid_client_nonce(value: &str) -> bool {
|
||||
value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn sign_server_proof(
|
||||
timestamp_text: &str,
|
||||
nonce: &str,
|
||||
bound_port: u16,
|
||||
pairing_token: &SharedExtensionToken,
|
||||
) -> Result<String, ()> {
|
||||
let token = pairing_token.read().unwrap_or_else(|e| e.into_inner());
|
||||
if token.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(token.as_bytes()).map_err(|_| ())?;
|
||||
mac.update(SERVER_PROOF_PREFIX);
|
||||
mac.update(timestamp_text.as_bytes());
|
||||
mac.update(b"\n");
|
||||
mac.update(nonce.as_bytes());
|
||||
mac.update(b"\n");
|
||||
mac.update(bound_port.to_string().as_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
Ok(encode_hex(signature.as_slice()))
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: &[u8]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) -> bool {
|
||||
let now = match current_time_millis() {
|
||||
Some(now) => now,
|
||||
@@ -383,8 +449,14 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{add_server_identity, PROTOCOL_VERSION_HEADER, SERVER_HEADER};
|
||||
use super::{
|
||||
add_server_identity, is_valid_client_nonce, sign_server_proof, PROTOCOL_VERSION_HEADER,
|
||||
SERVER_HEADER,
|
||||
};
|
||||
use axum::{http::StatusCode, middleware, routing::get, Router};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[tokio::test]
|
||||
async fn identifies_every_extension_server_response() {
|
||||
@@ -406,9 +478,48 @@ mod tests {
|
||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||
assert_eq!(
|
||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||
"2"
|
||||
"3"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_client_nonce_shape() {
|
||||
assert!(is_valid_client_nonce("0123456789abcdef0123456789abcdef"));
|
||||
assert!(is_valid_client_nonce("ABCDEF0123456789abcdef0123456789"));
|
||||
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcde"));
|
||||
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
||||
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
||||
let timestamp = "1710000000000";
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
let port = 6414;
|
||||
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(b"pairing-token").unwrap();
|
||||
mac.update(b"firelink-server-proof\n");
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(b"\n");
|
||||
mac.update(nonce.as_bytes());
|
||||
mac.update(b"\n");
|
||||
mac.update(port.to_string().as_bytes());
|
||||
let expected = mac
|
||||
.finalize()
|
||||
.into_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
|
||||
assert_eq!(
|
||||
sign_server_proof(timestamp, nonce, port, &token).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_ne!(
|
||||
sign_server_proof(timestamp, nonce, port + 1, &token).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user