Follow-up to #676: composer-install@v2 is composite but internally calls
actions/cache@v3 (Node 20), the last Node-20 deprecation on 2.x CI. 4.0.0
uses actions/cache v5.0.3 (Node 24) and keeps the composer-options input.
GitHub removes Node 20 runners on 2026-09-16; bump every Node-20 action
so v2 CI keeps working through the 2.x security-support window. checkout
v4->v6, setup-node v4->v6, paths-filter v3->v4, cancel-workflow-action
0.12.1->0.13.1, pnpm/action-setup v4->v6, docker/{setup-buildx v3->v4,
login v3->v4, metadata v5->v6, build-push v5->v7}. setup-php@v2,
ramsey/composer-install@v2 and svenstaro/upload-release-action@v2 are
already on node24.
Resolves the open Dependabot alerts on 2.x plus a laravel/framework
advisory surfaced by composer audit:
- vite 8.0.3 -> 8.0.5 (npm) — keeps rolldown 1.0.0-rc.12 so the build
stays correct; vite 8.0.15+ pulls the broken rolldown 1.0.3
- laravel/framework -> 13.15.0 (CVE-2026-48019, CRLF injection in the
default email validation rule)
- symfony/{mime,http-kernel,mailer,routing,yaml,polyfill-intl-idn} and
guzzlehttp/psr7 -> patched releases
composer audit clean; frontend build verified (no dangling chunk refs).
* build: migrate frontend tooling from yarn to pnpm
The Dockerfiles ran `yarn && yarn build`, which broke on node:24 (yarn no
longer on PATH; the corepack yarn shim made `npm i -g yarn` fail EEXIST),
while CI + Makefile used npm and only a yarn.lock was committed — an
inconsistent yarn/npm split. Standardize on pnpm, pinned via the
packageManager field + corepack.
- package.json: packageManager pnpm@11.6.0.
- pnpm-workspace.yaml: nodeLinker: hoisted (flat node_modules, npm/yarn-like, so
directly-imported transitive deps like flatpickr resolve) + allow vue-demi's
postinstall (it selects the Vue 3 entry). pnpm 11 reads these here, not .npmrc.
- Generate pnpm-lock.yaml (imported from yarn.lock); delete yarn.lock.
- Dockerfiles (dev/nginx/production): node:24 + `corepack enable && pnpm install --frozen-lockfile && pnpm build`.
- CI (check.yaml, docker.yaml): pnpm/action-setup + setup-node cache:pnpm; pnpm install --frozen-lockfile / pnpm build.
- Makefile, composer.json dev script, CLAUDE.md: npm/yarn -> pnpm.
pnpm build verified on a clean install (1425 modules, hoisted node_modules).
* fix(build): pin vite to 8.0.3 to fix rolldown chunk regression
vite 8.0.16 (pulled in by #653) bundles rolldown 1.0.3, which emits a
lazy chunk referencing an undefined Vue runtime-init function
(init_runtime_dom_esm_bundler), breaking the SPA at runtime. The build
succeeds so CI never caught it. Pin vite to 8.0.3 (the version 2.3.3
shipped, rolldown 1.0.0) which produces a correct bundle.
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.