Consume the injected CONTAINERIZED flag: expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User view/update authorized only that the requester owns their active company, not that the target user belonged to it, allowing an owner of one company to read or modify users of another. Require shared company membership in UserPolicy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Invoice/estimate/recurring-invoice creation and update accepted total,
sub_total, tax and due_amount straight from the request with no server-side
recalculation, so a client could persist financial totals that don't match the
line items (and, on invoice update, corrupt the due-amount/paid-amount logic
which keyed off the client total).
- Adds App\Support\DocumentTotals which recomputes item totals (round(price *
quantity) minus per-item discount) and document totals (sub_total, tax with
per-item vs document and tax_included handling, total, due_amount), mirroring
the front-end calculation. Only price/quantity/discounts/tax-line amounts are
trusted.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
client totals with the recomputed values; createItems recomputes each stored
item total; Invoice::updateInvoice keys its due-amount/paid-amount logic off
the recomputed total instead of the request total.
Adds DocumentTotals unit tests + a feature test proving a tampered invoice
total is ignored. Existing create/update tests no longer assert the (now
server-authoritative) derived totals.
gotenberg_host was validated only with Laravel's 'url' rule, which permits
loopback/private/link-local hosts (e.g. http://127.0.0.1, http://10.0.0.1,
the cloud metadata endpoint http://169.254.169.254). When a PDF renders, the
server POSTs the document HTML to that host — an SSRF primitive.
- Adds App\Rules\SafeRemoteUrl: requires http(s) and rejects any host that
resolves to a loopback/private/link-local/CGNAT/reserved address (IPv4 and
IPv6), including literal-IP hosts.
- Wires it into PDFConfigurationRequest for gotenberg_host.
- Adds a defensive re-check in GotenbergPDFDriver before the outbound call to
cover hosts set via env/seed/stale config or DNS rebinding (TOCTOU).
Adds unit tests for the rule + validator integration.
The orderByField/orderBy query params were passed straight into Eloquent's
orderBy() in every model's scopeWhereOrder (and Invoice::scopeApplyFilters),
allowing arbitrary SQL in the ORDER BY clause (boolean-based blind injection).
Adds App\Support\SafeOrderBy::apply() which only accepts a plain, optionally
table-qualified column identifier as the sort target (rejecting expressions,
sub-selects, etc.) and clamps the direction to asc/desc. Routed all 10 model
sort sinks through it. Table-qualified columns stay valid, so joined/aliased
sorts (e.g. estimates by customers.name) are unaffected.
Adds unit tests covering injection rejection, plain + aliased columns, and
direction clamping.
Customer PDF controllers resolved the target document by raw mailable_id,
ignoring mailable_type, and skipped the expiry check on the JSON endpoints.
- Resolve via the $emailLog->mailable morph relation and assert the expected
type (abort 404) so a token issued for one document type can't disclose
another whose numeric id collides.
- Enforce isExpired() (abort 403) on every public path, including the JSON
getInvoice/getEstimate/getPayment endpoints.
- Harden EmailLog::isExpired() to treat a null/unresolvable mailable as
expired instead of throwing.
Adds tests for cross-type 404, JSON-path expiry 403, and the valid path.
- Notes IDOR (GHSA-85wc): NotePolicy::viewNotes/manageNotes now receive the
Note and require hasCompany($note->company_id); NotesController passes the
bound model to authorize() on show/update/destroy.
- Estimate->Invoice IDOR (GHSA-j2vg): ConvertEstimateController authorizes
'view' on the source estimate before creating the invoice.
- User bulk-delete (GHSA-wxrv): UsersController scopes candidate ids via
User::whereCompany() before deletion so cross-company accounts are protected.
Adds feature tests for cross-company 403s plus same-company happy paths.
The v2 self-updater only overlays new files (copyFiles) and never removes
files a release deleted; the one removal path (deleted_files) is not even
sent by the web UI. A major upgrade (v2 → v3) removes thousands of files,
so overlay-only leaves a broken hybrid, and stale bootstrap/cache config +
package-discovery survive and break the new boot.
Backport v3's manifest allow-list approach into this final v2 release:
- Updater::cleanStaleFiles(?string $basePath): delete every file under the
install not listed in the release's manifest.json, except the configured
update_protected_paths. No manifest present → safe no-op (v2→v2 updates).
- Updater::clearCompiledCaches(): wipe bootstrap/cache/*.php and compiled
views so the freshly copied release re-reads config and re-runs package
discovery. Called at the end of copyFiles() — the last point that runs as
the currently-installed code before the new release boots, and necessary
because bootstrap/cache is itself a protected path.
- DeleteFilesController + UpdateCommand: when manifest.json is present, run
cleanStaleFiles(); otherwise fall back to the legacy deleted_files list.
No route or frontend change — both already call the delete step between
copy and migrate.
- config/invoiceshelf.php: add update_protected_paths (.env, storage,
vendor, node_modules, Modules, public/storage, .git, bootstrap/cache,
manifest.json).
The v3 release zip already ships manifest.json (built by its make dist), so
a v2 instance running this updater cleans itself up correctly on upgrade.
Tested: tests/Unit/UpdaterTest.php covers stale removal, protected-path and
manifest preservation, empty-dir pruning, no-manifest no-op, and invalid
manifest. Full suite green.
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). The original fix in 07757e74 only sanitized the Notes field via Invoice/Estimate/Payment::getNotes(), but the same blade templates also render company/billing/shipping address fields with {!! !!} (Blade unescaped output). Those address strings are produced by getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() which feed into GeneratesPdfTrait::getFormattedString() — and that method does not call PdfHtmlSanitizer.
Customer-controlled fields (name, street, phone, custom field values) are substituted into address templates via getFieldsArray() without HTML-escaping. A malicious customer name like "Acme <img src='http://attacker/probe'>" therefore reaches Dompdf as raw HTML through the address path, exactly the same CWE-918 SSRF pattern the advisories describe — only blocked today 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 the PdfHtmlSanitizer::sanitize() call 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. The explicit wrapper in each model's getNotes() becomes redundant and is removed (along with the now-unused App\Support\PdfHtmlSanitizer imports). Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly) so there is no risk of stripping useful HTML from a non-PDF context.
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.
Add expenses.uncategorized and pdf_expense_group_total_label; use the new key
in the grouped expense template; document View|Response instead of JsonResponse.
Made-with: Cursor
Add scopeWhereCompany() to User model using whereHas through the
user_company pivot table. Apply it in UsersController::index() and
SearchController so users only see members of their current company.
Previously, the users page showed ALL users across all companies.
Ref #574
Verify the source record belongs to the current company before cloning.
Previously, users could clone invoices/estimates from other companies,
leaking sensitive data (amounts, customer details, items, taxes, notes).
The view policy already includes hasCompany() check, so authorizing
view on the source record gates both ability and company ownership.
Ref #574
Bulk delete: filter IDs through whereCompany() before deleting in all
controllers (Invoices, Payments, Items, Expenses, Estimates, Recurring
Invoices). Previously, any user could delete records from other companies
by providing cross-company IDs.
Transfer ownership: fix inverted hasCompany() check that allowed
transferring company ownership to users who do NOT belong to the company,
while blocking users who DO belong.
Ref #567
* Fix CustomerPolicy missing hasCompany() check (cross-company IDOR)
Add $user->hasCompany($customer->company_id) check to view, update,
delete, restore, and forceDelete methods in CustomerPolicy, matching
the pattern used by all other policies (InvoicePolicy, PaymentPolicy,
EstimatePolicy, etc.).
Without this check, a user in Company A with view-customer ability
could access customers belonging to Company B by providing the target
customer's ID.
Add cross-company authorization tests to verify the fix.
Closes#565
* Scope bulk delete to current company to prevent cross-company deletion
Filter customer IDs through whereCompany() before passing to
deleteCustomers(), ensuring users cannot delete customers belonging
to other companies via the bulk delete endpoint.
* docs: add CLAUDE.md for Claude Code guidance
* fix: handle missing settings table in installation middlewares
RedirectIfInstalled crashed with "no such table: settings" when the
database_created marker file existed but the database was empty.
Changed to use isDbCreated() which verifies actual tables, and added
try-catch around Setting queries in both middlewares.
* feat: pre-select database driver from env in installation wizard
The database step now reads DB_CONNECTION from the environment and
pre-selects the matching driver on load, including correct defaults
for hostname and port.
* feat: pre-select mail driver and config from env in installation wizard
The email step now fetches the current mail configuration on load
instead of hardcoding the driver to 'mail'. SMTP fields fall back
to Laravel config values from the environment.
* refactor: remove file-based DB marker in favor of direct DB checks
The database_created marker file was a second source of truth that
could drift out of sync with the actual database. InstallUtils now
checks the database directly via Schema::hasTable which is cached
per-request and handles all error cases gracefully.
Three bugs prevented backups stored on remote disks (Dropbox, S3, etc.)
from ever appearing in the Settings > Backup listing:
1. Typo in BackupSetting.vue: `filed_disk_id` was sent to the API
instead of `file_disk_id`, so the backend never received the selected
disk ID and always fell back to the local filesystem.
2. Wrong default disk selection in loadDisksData(): `set_as_default == 0`
selected the first disk that is NOT default (local_public), instead of
the disk that IS default. Changed to `set_as_default == 1` with a
fallback to the first disk.
3. BackupsController::index() did not call setConfig() on the FileDisk
before querying backups, so even when the correct file_disk_id arrived
it still read from the default local filesystem. Added the same disk
bootstrap logic already present in CreateBackupJob and destroy().
Made-with: Cursor
* Update CustomersController.php
Fix: replace GROUP BY + SUM join with withSum() to avoid ONLY_FULL_GROUP_BY; no API changes (same aliases)
* Update CustomersController.php
style: apply Laravel Pint formatting
* Upgrade the mail configuration
* Update mail configuration to match Laravel 12
* Update mail configuration to properly set none or null
* Pint code
* Upgrade Symfony Mailers
Previously, the customer chart used the total/amount fields to calculate net profits/expenses/etc.
If the currency the expense (for example) was created in differed from the base currency of the company, the chart would display wrong amounts.
This change addresses the issue by always using the base currency field.
* feat: Tax included
* Added a toggle switch in tax settings to enable the feature.
* Database migration adding tax_included field into estimates, invoices
and recurring invoices table.
* Toggle switch to enable and store the tax_included by estimates,
invoices and recurring invoices.
* In case of tax included enabled, total taxes will be recalculated and
the invoices, estimates and recurring invoices total won't be sum with
taxes.
* Apply tax included when discount_per_item/tax_per_item item is enabled.
* Custom component to show the net total when tax included is enabled.
* Update invoice and estimates pdfs with net total.
* chore: Tax included by default
A switch button inside the tax settings to enable the tax included by
default in invoices, estimates and recurring invoices.
Since `laravel-backup` major version was updated 8 to 9, the backup
ability was compromised, the main reason is the change on the method
contract from `BackupFactory` that now the `createFromArray` no longer
exists.
Error caused when using Payments module, when try Stripe redirects back
to InvoiceShelf, and the module calls the InvoiceShelf `generatePayment`.
Relates #369
* Possibility to set a fixed amount on tax types settings
* Pint and manage flat taxes on items
* Fix display errors and handle global taxes
* Tests
* Pint with PHP 8.2 cause with PHP 8.3 version it cause workflow error
* Merging percent and fixed amount into one column
* Now display the currency on SelectTaxPopup on fixed taxes