Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/JpaUnarchiver.php
T
Jonathan Miller 2395a4eabc
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 17s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7m48s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
fix: critical and high audit findings (#81)
Fixes all critical and high severity issues from the codebase audit:

CRITICAL:
- #71: RestoreCommand passed wrong args to RestoreEngine (filepath
  instead of record ID) — CLI restore was completely broken
- #72: JpaUnarchiver path traversal — added traversal rejection and
  realpath boundary check to prevent writes outside staging dir
- #77: RestoreEngine staging path sanitized — $record->tag stripped
  of non-alphanumeric characters

HIGH:
- #75: (noted, AkeebaImporter unserialize needs separate refactor)
- #76: BackupTable now deletes DB row before file — prevents data
  loss if DB delete fails
- #78: API profiles endpoint now masks sensitive fields (passwords,
  keys, tokens) with '***'
- #79: Webcron handler adds return after sendJsonResponse — prevents
  execution falling through on non-terminal close()
- #80: BackupModel/ProfileModel loadFormData() now casts array to
  object — prevents TypeError on PHP 8.x form state restore

PREFLIGHT HARDENING:
- PreflightCheck::run() wrapped in try-catch for DB exceptions
- mkdir() failure now includes actual error reason
- Unresolved placeholders generate a warning instead of silent return

Closes #71, closes #76, closes #77, closes #78, closes #79, closes #80
Ref #72, ref #81
2026-06-21 18:08:58 -05:00

291 lines
7.1 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* JPA (Joomla Pack Archive) unarchiver for importing Akeeba Backup files.
*
* JPA Format Structure:
* - Header: signature (3 bytes "JPA"), header length, major/minor version
* - Entity headers: signature (3 bytes), header length, path length, path,
* compression type (0=none, 1=gzip), compressed size, uncompressed size,
* permissions, then compressed data
*
* Read-only: extracts JPA archives to a staging directory.
* The RestoreEngine can then restore from the extracted files.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
class JpaUnarchiver
{
private const JPA_SIGNATURE = "\x4a\x50\x41"; // "JPA"
private const ENTITY_SIGNATURE = "\x4a\x50\x46"; // "JPF" — file entity
private string $archivePath;
private string $outputDir;
private int $filesExtracted = 0;
public function __construct(string $archivePath, string $outputDir)
{
$this->archivePath = $archivePath;
$this->outputDir = rtrim($outputDir, '/\\');
}
/**
* Extract a JPA archive to the output directory.
*
* @return int Number of files extracted
*
* @throws \RuntimeException On format errors or extraction failure
*/
public function extract(): int
{
if (!is_file($this->archivePath) || !is_readable($this->archivePath)) {
throw new \RuntimeException('JPA file not readable: ' . $this->archivePath);
}
$handle = fopen($this->archivePath, 'rb');
if ($handle === false) {
throw new \RuntimeException('Cannot open JPA file: ' . $this->archivePath);
}
try {
// Read and validate archive header
$this->readArchiveHeader($handle);
// Read entities until EOF
while (!feof($handle)) {
$pos = ftell($handle);
// Try to read entity signature
$sig = fread($handle, 3);
if ($sig === false || strlen($sig) < 3) {
break; // End of archive
}
if ($sig === self::ENTITY_SIGNATURE) {
$this->readFileEntity($handle);
} else {
// Unknown entity — try to skip by reading header length
fseek($handle, $pos + 3);
$headerLenData = fread($handle, 2);
if ($headerLenData === false || strlen($headerLenData) < 2) {
break;
}
$headerLen = unpack('v', $headerLenData)[1];
// Skip remaining header + data
fseek($handle, $pos + 3 + $headerLen);
}
}
return $this->filesExtracted;
} finally {
fclose($handle);
}
}
/**
* Read and validate the JPA archive header.
*/
private function readArchiveHeader($handle): void
{
$signature = fread($handle, 3);
if ($signature !== self::JPA_SIGNATURE) {
throw new \RuntimeException('Not a valid JPA archive — invalid signature');
}
// Header length (2 bytes, little-endian)
$headerLenData = fread($handle, 2);
if ($headerLenData === false || strlen($headerLenData) < 2) {
throw new \RuntimeException('Truncated JPA header');
}
$headerLen = unpack('v', $headerLenData)[1];
// Version: major (1 byte), minor (1 byte)
$versionData = fread($handle, 2);
if ($versionData === false || strlen($versionData) < 2) {
throw new \RuntimeException('Cannot read JPA version');
}
// File count (4 bytes, little-endian)
$countData = fread($handle, 4);
if ($countData === false || strlen($countData) < 4) {
throw new \RuntimeException('Cannot read file count');
}
// Skip any remaining header bytes
$bytesRead = 3 + 2 + 2 + 4; // sig + headerLen + version + count
if ($headerLen > ($bytesRead - 3)) {
$remaining = $headerLen - ($bytesRead - 3);
if ($remaining > 0) {
fseek($handle, ftell($handle) + $remaining);
}
}
}
/**
* Read a single file entity and extract it.
*/
private function readFileEntity($handle): void
{
// Entity header length (2 bytes)
$headerLenData = fread($handle, 2);
if ($headerLenData === false || strlen($headerLenData) < 2) {
return;
}
$headerLen = unpack('v', $headerLenData)[1];
// Path length (2 bytes)
$pathLenData = fread($handle, 2);
if ($pathLenData === false || strlen($pathLenData) < 2) {
return;
}
$pathLen = unpack('v', $pathLenData)[1];
// Path (variable)
$path = fread($handle, $pathLen);
if ($path === false || strlen($path) < $pathLen) {
return;
}
// Compression type (1 byte): 0 = none, 1 = gzip
$compTypeData = fread($handle, 1);
$compType = ord($compTypeData);
// Compressed size (4 bytes)
$compSizeData = fread($handle, 4);
$compSize = unpack('V', $compSizeData)[1];
// Uncompressed size (4 bytes)
$uncompSizeData = fread($handle, 4);
$uncompSize = unpack('V', $uncompSizeData)[1];
// Permissions (4 bytes)
$permsData = fread($handle, 4);
$perms = unpack('V', $permsData)[1];
// Skip any remaining header bytes
$entityHeaderRead = 2 + 2 + $pathLen + 1 + 4 + 4 + 4;
$entityHeaderTotal = $headerLen;
if ($entityHeaderTotal > $entityHeaderRead) {
fseek($handle, ftell($handle) + ($entityHeaderTotal - $entityHeaderRead));
}
// Read compressed data
$data = '';
if ($compSize > 0) {
$data = fread($handle, $compSize);
if ($data === false || strlen($data) < $compSize) {
return;
}
}
// Path traversal protection: reject absolute paths and directory traversal
if (str_starts_with($path, '/') || str_starts_with($path, '\\') || str_contains($path, '..')) {
return; // skip malicious entry
}
// Is this a directory?
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
$dirPath = $this->outputDir . '/' . $path;
if (!is_dir($dirPath)) {
mkdir($dirPath, 0755, true);
}
return;
}
// Decompress if needed
if ($compType === 1 && !empty($data)) {
$data = @gzinflate($data);
if ($data === false) {
throw new \RuntimeException('Failed to decompress file: ' . $path);
}
}
// Write file
$fullPath = $this->outputDir . '/' . $path;
// Verify resolved path stays within output directory
$realOutput = realpath($this->outputDir);
if ($realOutput !== false) {
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
$realDest = realpath($parentDir);
if ($realDest === false || !str_starts_with($realDest, $realOutput)) {
return; // path escapes staging directory
}
}
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
file_put_contents($fullPath, $data);
// Set permissions (only if reasonable)
if ($perms > 0 && $perms <= 0777) {
@chmod($fullPath, $perms);
}
$this->filesExtracted++;
}
/**
* Check if a file appears to be a JPA archive.
*/
public static function isJpaFile(string $path): bool
{
if (!is_file($path) || !is_readable($path)) {
return false;
}
$handle = fopen($path, 'rb');
if ($handle === false) {
return false;
}
$sig = fread($handle, 3);
fclose($handle);
return $sig === self::JPA_SIGNATURE;
}
}