mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 17:24:10 +00:00
Closes the residual surface from the three published SSRF advisories (GHSA-pc5v-8xwc-v9xq, GHSA-38hf-fq8x-q49r, GHSA-q9wx-ggwq-mcgh / CVE-2026-34365 to 34367) that the original 2.2.0 fix only covered for the Notes field. The same blade templates render company/billing/shipping address fields with {!! !!} via Invoice/Estimate/Payment::getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() — and those flow through GeneratesPdfTrait::getFormattedString() which did not call PdfHtmlSanitizer.
Customer-controlled fields (name, street, phone, custom-field values) are substituted into address templates via getFieldsArray() without HTML-escaping, so a malicious customer name like "Acme <img src='http://attacker/probe'>" reaches Dompdf as raw HTML through the address path. Today this is blocked only by the secondary defense of dompdf's enable_remote=false; if a self-hoster sets DOMPDF_ENABLE_REMOTE=true for legitimate remote logos, the address surface immediately re-opens.
Move PdfHtmlSanitizer::sanitize() into the chokepoint at GeneratesPdfTrait::getFormattedString() so all four sinks — notes plus the three address fields, on all three models — get the same treatment via a single call site. v3.0's models (Invoice, Estimate, Payment) already had the simpler getNotes() shape (no per-method PdfHtmlSanitizer wrapper), so the trait edit alone is sufficient — no model edits required on this branch. Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly).
This is the v3.0 counterpart to master's f387e751. Re-implemented directly on v3.0 instead of cherry-picked because the import-block divergence from the larger v3.0 refactor produced four merge conflicts that were noisier than just porting the chokepoint change manually.
Extends tests/Unit/PdfHtmlSanitizerTest.php with three new cases covering the address-template scenario, iframe/link tag stripping, and on* event handler removal. All 8 tests pass via vendor/bin/pest tests/Unit/PdfHtmlSanitizerTest.php.
197 lines
6.9 KiB
PHP
197 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Traits;
|
|
|
|
use App\Models\Address;
|
|
use App\Models\CompanySetting;
|
|
use App\Models\FileDisk;
|
|
use App\Models\Setting;
|
|
use App\Services\FontService;
|
|
use App\Support\PdfHtmlSanitizer;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\App;
|
|
|
|
trait GeneratesPdfTrait
|
|
{
|
|
public function getGeneratedPDFOrStream($collection_name)
|
|
{
|
|
$pdf = $this->getGeneratedPDF($collection_name);
|
|
if ($pdf && file_exists($pdf['path'])) {
|
|
return response()->make(file_get_contents($pdf['path']), 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.$pdf['file_name'].'"',
|
|
]);
|
|
}
|
|
|
|
$locale = CompanySetting::getSetting('language', $this->company_id);
|
|
|
|
App::setLocale($locale);
|
|
app(FontService::class)->ensureFontsForLocale($locale);
|
|
|
|
$pdf = $this->getPDFData();
|
|
|
|
return response()->make($pdf->stream(), 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.$this[$collection_name.'_number'].'.pdf"',
|
|
]);
|
|
}
|
|
|
|
public function getGeneratedPDF($collection_name)
|
|
{
|
|
try {
|
|
$media = $this->getMedia($collection_name)->first();
|
|
|
|
if ($media) {
|
|
$file_disk = FileDisk::find($media->custom_properties['file_disk_id']);
|
|
|
|
if (! $file_disk) {
|
|
return false;
|
|
}
|
|
|
|
$file_disk->setConfig();
|
|
|
|
$path = null;
|
|
|
|
if ($file_disk->driver == 'local') {
|
|
$path = $media->getPath();
|
|
} else {
|
|
$path = $media->getTemporaryUrl(Carbon::now()->addMinutes(5));
|
|
}
|
|
|
|
return collect([
|
|
'path' => $path,
|
|
'file_name' => $media->file_name,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function generatePDF($collection_name, $file_name, $deleteExistingFile = false)
|
|
{
|
|
$save_pdf_to_disk = Setting::getSetting('save_pdf_to_disk') ?? 'NO';
|
|
|
|
if ($save_pdf_to_disk == 'NO') {
|
|
return 0;
|
|
}
|
|
|
|
$locale = CompanySetting::getSetting('language', $this->company_id);
|
|
|
|
App::setLocale($locale);
|
|
app(FontService::class)->ensureFontsForLocale($locale);
|
|
|
|
$pdf = $this->getPDFData();
|
|
|
|
\Storage::disk('local')->put('temp/'.$collection_name.'/'.$this->id.'/temp.pdf', $pdf->output());
|
|
|
|
if ($deleteExistingFile) {
|
|
$this->clearMediaCollection($this->id);
|
|
}
|
|
|
|
$file_disk = FileDisk::whereSetAsDefault(true)->first();
|
|
|
|
if ($file_disk) {
|
|
$file_disk->setConfig();
|
|
}
|
|
|
|
$media = \Storage::disk('local')->path('temp/'.$collection_name.'/'.$this->id.'/temp.pdf');
|
|
|
|
try {
|
|
$this->addMedia($media)
|
|
->withCustomProperties(['file_disk_id' => $file_disk->id])
|
|
->usingFileName($file_name.'.pdf')
|
|
->toMediaCollection($collection_name, config('filesystems.default'));
|
|
|
|
\Storage::disk('local')->deleteDirectory('temp/'.$collection_name.'/'.$this->id);
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return $e->getMessage();
|
|
}
|
|
}
|
|
|
|
public function getFieldsArray()
|
|
{
|
|
$customer = $this->customer;
|
|
$shippingAddress = $customer->shippingAddress ?? new Address;
|
|
$billingAddress = $customer->billingAddress ?? new Address;
|
|
$companyAddress = $this->company->address ?? new Address;
|
|
|
|
$fields = [
|
|
'{SHIPPING_ADDRESS_NAME}' => $shippingAddress->name,
|
|
'{SHIPPING_COUNTRY}' => $shippingAddress->country_name,
|
|
'{SHIPPING_STATE}' => $shippingAddress->state,
|
|
'{SHIPPING_CITY}' => $shippingAddress->city,
|
|
'{SHIPPING_ADDRESS_STREET_1}' => $shippingAddress->address_street_1,
|
|
'{SHIPPING_ADDRESS_STREET_2}' => $shippingAddress->address_street_2,
|
|
'{SHIPPING_PHONE}' => $shippingAddress->phone,
|
|
'{SHIPPING_ZIP_CODE}' => $shippingAddress->zip,
|
|
'{BILLING_ADDRESS_NAME}' => $billingAddress->name,
|
|
'{BILLING_COUNTRY}' => $billingAddress->country_name,
|
|
'{BILLING_STATE}' => $billingAddress->state,
|
|
'{BILLING_CITY}' => $billingAddress->city,
|
|
'{BILLING_ADDRESS_STREET_1}' => $billingAddress->address_street_1,
|
|
'{BILLING_ADDRESS_STREET_2}' => $billingAddress->address_street_2,
|
|
'{BILLING_PHONE}' => $billingAddress->phone,
|
|
'{BILLING_ZIP_CODE}' => $billingAddress->zip,
|
|
'{COMPANY_NAME}' => $this->company->name,
|
|
'{COMPANY_COUNTRY}' => $companyAddress->country_name,
|
|
'{COMPANY_STATE}' => $companyAddress->state,
|
|
'{COMPANY_CITY}' => $companyAddress->city,
|
|
'{COMPANY_ADDRESS_STREET_1}' => $companyAddress->address_street_1,
|
|
'{COMPANY_ADDRESS_STREET_2}' => $companyAddress->address_street_2,
|
|
'{COMPANY_PHONE}' => $companyAddress->phone,
|
|
'{COMPANY_ZIP_CODE}' => $companyAddress->zip,
|
|
'{COMPANY_VAT}' => $this->company->vat_id,
|
|
'{COMPANY_TAX}' => $this->company->tax_id,
|
|
'{CONTACT_DISPLAY_NAME}' => $customer->name,
|
|
'{PRIMARY_CONTACT_NAME}' => $customer->contact_name,
|
|
'{CONTACT_EMAIL}' => $customer->email,
|
|
'{CONTACT_PHONE}' => $customer->phone,
|
|
'{CONTACT_WEBSITE}' => $customer->website,
|
|
'{CONTACT_TAX_ID}' => __('pdf_tax_id').': '.$customer->tax_id,
|
|
];
|
|
|
|
$customFields = $this->fields;
|
|
$customerCustomFields = $this->customer->fields;
|
|
|
|
foreach ($customFields as $customField) {
|
|
$fields['{'.$customField->customField->slug.'}'] = $customField->defaultAnswer;
|
|
}
|
|
|
|
foreach ($customerCustomFields as $customField) {
|
|
$fields['{'.$customField->customField->slug.'}'] = $customField->defaultAnswer;
|
|
}
|
|
|
|
foreach ($fields as $key => $field) {
|
|
$fields[$key] = htmlspecialchars($field, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
public function getFormattedString($format)
|
|
{
|
|
$values = array_merge($this->getFieldsArray(), $this->getExtraFields());
|
|
|
|
$str = nl2br(strtr($format, $values));
|
|
|
|
$str = preg_replace('/{(.*?)}/', '', $str);
|
|
|
|
$str = preg_replace("/<[^\/>]*>([\s]?)*<\/[^>]*>/", '', $str);
|
|
|
|
$str = str_replace('<p>', '', $str);
|
|
|
|
$str = str_replace('</p>', '<br />', $str);
|
|
|
|
// Sanitize the assembled HTML to strip any SSRF vectors that may have
|
|
// entered through user-supplied address fields, customer names, or
|
|
// custom field values. Notes also pass through this method, so they
|
|
// get the same treatment without needing a separate wrapper.
|
|
return PdfHtmlSanitizer::sanitize($str);
|
|
}
|
|
}
|