fix(security): harden extension and engine trust

This commit is contained in:
NimBold
2026-06-29 17:11:19 +03:30
parent e79d32af74
commit 01ca6e604c
7 changed files with 247 additions and 77 deletions
+45
View File
@@ -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') };
}
+42
View File
@@ -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 });
}
});
+4 -15
View File
@@ -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,
+4 -35
View File
@@ -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
View File
@@ -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 ───');
+120 -9
View File
@@ -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
);
}
}