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>
Remove scheduled nightly/alpha builds; gate :latest on LATEST_MAJOR; keep a transitional :nightly alias on stable releases. Fix the production Dockerfile so a stale host public/build cannot clobber the freshly built frontend.
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>
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.