27951 Commits
Author SHA1 Message Date
Ruslan Konviser 3f45758e76 Merge pull request #10250 from ever-co/fix/media-plugin-employee-scope
fix(security): scope video, camshot and soundshot reads to the caller's own recordings
v111.44.13
2026-09-21 06:17:48 +02:00
Ruslan Konviser c0b41d4c34 Merge pull request #10247 from ever-co/fix/screenshot-delete-ownership
fix(security): restrict screenshot deletion to the caller's own screenshots
v111.44.12
2026-09-21 00:10:10 +02:00
Ruslan Konviser fa5ede68c9 Merge pull request #10246 from ever-co/fix/weekly-report-500
fix(core): stop reports 500ing when the request names no time zone
v111.44.11
2026-09-20 23:57:41 +02:00
Ruslan Konviser 5453e2de2d Merge pull request #10252 from ever-co/fix/sensitive-relations-tracked-data
fix(security): gate tracked data reached through client-supplied relations
2026-09-20 23:57:37 +02:00
Ruslan Konviser f76472617e Merge pull request #10251 from ever-co/fix/custom-tracking-employee-scope
fix(security): scope custom-tracking sessions to the employees the caller may see
2026-09-20 23:57:31 +02:00
Ruslan Konviser 019b369bde Merge pull request #10249 from ever-co/fix/null-employee-identity
fix(security): treat a missing employee identity as no access, not no filter
2026-09-20 23:57:20 +02:00
Ruslan KonviserandClaude Opus 5 59b3dba7af fix(security): stop exempting DEMO from the signing-secret rules (GHSA-39j7) (#10253)
The first pass left `DEMO=true` on the historical path — an unset secret fell
back to the literal published in this repository — because it could not be
verified whether demo.gauzy.co relied on that fallback, and develop deploys
straight there.

It does not. Every deployment in the fleet sets all four secrets explicitly
(JWT_SECRET, JWT_REFRESH_TOKEN_SECRET, JWT_VERIFICATION_TOKEN_SECRET,
EXPRESS_SESSION_SECRET are 64-96 bytes in ever-gauzy dev/stage/prod and
ever-teams dev/stage/prod), so the exemption protected nothing here while
leaving any other DEMO deployment signing tokens with a key anyone can read in
this repository — the hole the advisory describes.

DEMO now behaves like every other environment: an explicitly set secret is used
as-is, an unset one gets the per-process random value, and the startup guard
still reports that as unconfigured. `resolveSecret()` loses its second argument,
which only existed to carry the literal it was allowed to return.

The spec's DEMO block now asserts the opposite of what it used to, with a
control arm that shows a token forged with the published key verifying against
the old fallback and failing against the new value.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.10
2026-09-20 23:54:13 +02:00
joel kalema b781271668 Merge pull request #10236 from joel-kalema/feat/polish-goals-dialogs
Feat/polish goals dialogs
v111.44.9
2026-09-20 23:50:40 +02:00
Ruslan Konviser 23683add1b Merge pull request #10248 from ever-co/fix/candidate-billing-rate-decimals
fix(employee,candidate): cap billing rates, carry minimum rate on hire, honor transformers under MikroORM
v111.44.8
2026-09-20 22:13:20 +02:00
Ruslan KonviserandClaude Opus 5 3e1a8810b1 fix(security): scope video, camshot and soundshot reads to the caller's own recordings
The three media plugins let a caller read another employee's recordings, with only TIME_TRACKER —
a default EMPLOYEE permission.

- The list handlers pinned `uploadedById` to the caller and then spread the client's `where` OVER it
  (`where: { ...where, ...params.where }`), so `?where[uploadedById]=<someone else>` won. The server's
  keys now go last, which also stops a client-supplied `tenantId`/`organizationId` from replacing the
  resolved ones.
- The by-id handlers were tenant-scoped only. These entities carry `uploadedById`, not `employeeId`,
  so the per-employee restriction in TenantAwareCrudService never applied to them. They now go through
  `assertCallerOwnsUpload` in core, shared by all three plugins rather than copied into each: it
  answers 404 for a record the caller did not upload, so the id of someone else's recording stays
  unconfirmed, and leaves CHANGE_SELECTED_EMPLOYEE holders alone.

Specs: the ownership rule in core, and per plugin the merge order, the tenant/organization keys and
the delegation. Camshot's tsconfig.lib.json now excludes spec files, as the videos and soundshot ones
already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:32:48 +02:00
Ruslan KonviserandClaude Opus 5 c3ab58e738 fix(employee,candidate): address review — cap rates at the old integer limit, support transformer arrays
- BILLING_RATE_MAX is now 2147483647, the old integer column's maximum, so every accepted rate
  stays revertible: rolling the columns back to integer cannot overflow on a value saved after the
  upgrade (Greptile P1). numeric(14,2) stays wider; the cap can be raised without a migration.
- ValueTransformerType supports TypeORM's ValueTransformer[]: `to` in array order, `from` reversed,
  matching ApplyValueTransformers. Calling .to()/.from() on the array threw before (CodeRabbit).
- The wrapper and the property now report the same DDL, including an explicit columnType, and
  declaredColumnType keeps length and precision-only modifiers (CodeRabbit).
- Real MikroORM round trip on better-sqlite: generated DDL, write conversion, hydration and null
  handling for a numeric and an int-backed enum column (Greptile P2). Verified by mutation: it
  fails without the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:24:41 +02:00
Ruslan KonviserandClaude Opus 5 f915b69e10 fix(security): restrict screenshot deletion to the caller's own screenshots
`deleteScreenshot` limits a caller without CHANGE_SELECTED_EMPLOYEE to their own screenshots by
joining the owning time slot. The TypeORM branch used a LEFT join, which keeps the row when its ON
clause does not match, so the ownership condition removed nothing: the effective filter was id +
tenant + organization. DELETE_SCREENSHOTS is a default EMPLOYEE permission, so any member of an
organization could delete a colleague's screenshot, and with `forceDelete` the stored image as well.
Ids come from any of the list endpoints.

- INNER join instead, so a screenshot whose slot belongs to someone else cannot match.
- A caller with no employee identity and no CHANGE_SELECTED_EMPLOYEE now gets 403 instead of having
  the ownership predicate quietly dropped; the same guard is applied to the MikroORM branch, where a
  null id would otherwise have searched for slots with a NULL employeeId.
- Spec covers all three cases; two of them fail on the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:51:44 +02:00
Ruslan KonviserandClaude Opus 5 685950bd4b fix(core): let a deliberate HTTP answer through the team statistic handler
`GetOrganizationTeamStatisticHandler` flattened every error into
`BadRequestException('Failed to execute organization team statistic query')`. The sensitive-relation
check answers 403 from inside that call, so a caller probing
`GET /organization-team/:id?relations[]=members.employee.timeSlots.screenshots` was told the request
was malformed rather than forbidden — the data was blocked either way, but the reason was masked, and
so was every other deliberate HTTP answer from below.

An `HttpException` now passes through unchanged; anything else still becomes the generic 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:40:20 +02:00
Ruslan KonviserandClaude Opus 5 934471957e fix(security): gate tracked data reached through client-supplied relations
The per-employee restriction in TenantAwareCrudService applies to the ROOT entity of a read, so a
client-supplied `relations` that starts from a row the whole organization may read walks straight
into everybody's tracked data. Both of these are reachable with default EMPLOYEE permissions
(ORG_TASK_VIEW, ORG_TEAM_VIEW) and were reproduced against a booted API:

    GET /tasks/:id?relations[]=timeLogs.timeSlots.screenshots
    GET /organization-team/:id?relations[]=members.employee.timeSlots.screenshots

- `TRACKED_DATA_SENSITIVE_RELATIONS` names the hops that cross from a shared entity (Employee, Task,
  OrganizationProject, OrganizationTeam) into tracked data, and requires CHANGE_SELECTED_EMPLOYEE for
  them. The existing walk already gates by the entity a relation is loaded from, so it needed only to
  arm this table per hop, merged with whatever the organization table declared — `employees.user`
  keeps needing ORG_USERS_VIEW.
- `TimeLog.timeSlots` and `TimeSlot.screenshots` are deliberately NOT gated: those reads start from a
  row already scoped to the caller's own employee id (the desktop retry queue, the screenshot modal),
  and reaching someone else's slot needs one of the hops above first.
- `GET /timesheet/timer/status/worked` may legitimately name a teammate, so the table cannot help
  there: it narrows the requested relations instead, dropping the tracked-data ones for a caller
  without CHANGE_SELECTED_EMPLOYEE. Narrowing rather than refusing keeps existing clients working.

Specs cover the three exploited paths, the admin bypass, the self-service reads that must keep
working, a same-named relation on an unrelated entity, and the narrowing helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:03:46 +02:00
Ruslan KonviserandClaude Opus 5 bf33e5bdf4 fix(security): scope custom-tracking sessions to the employees the caller may see
`getTrackingSessions` took the `employeeIds` of the request as given, with no permission and no
manager check, and the controller gate is OR-semantics over TIME_TRACKER / ALL_ORG_EDIT /
ALL_ORG_VIEW. Every default EMPLOYEE holds TIME_TRACKER, so
`GET /timesheet/custom-tracking/sessions?employeeIds[]=<colleague>&includeDecodedData=true` returned
that colleague's sessions and their decoded payloads. Reproduced against a booted API.

The ids now go through `ManagedEmployeeService.filterAccessibleEmployeeIds`, the same gate the
time-log and statistics reads use, with the request's team and project scope passed along so a
manager keeps their reach. CustomTrackingModule imports EmployeeModule for it, the way TimeLogModule
already does.

The `#10212` guard on these routes does not help here: it enforces an organization setting, it does
not scope by employee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:42:20 +02:00
Ruslan KonviserandClaude Opus 5 1e0fe90447 fix(security): Inherited mutating routes + authz residuals (GHSA-v79w, GHSA-c3cj, GHSA-44pv) (#10242)
* fix(security): gate inherited mutating routes and close authorization residuals

GHSA-v79w-54p2-wmh5 (high): CrudController's inherited POST/PUT/DELETE/soft/
recover routes carry no permission metadata, and PermissionGuard passes when the
metadata is empty, so an EMPLOYEE could soft-delete users, contacts and tags.
Those routes are now gated, and a repo-wide spec walks every controller in core
and the plugins (966 mutating routes) and fails on any ungated one unless it is
explicitly opted in: 34 deliberately open routes, each with the service-side
check that covers it, plus a frozen ratchet list of pre-existing bare routes that
may only shrink. crud.service softRecover also passed no withDeleted, so every
inherited recover route answered 404 for the row it exists to restore.

GHSA-c3cj-m3xm-7j5h (high): the organization-contact lookup built its joins
straight from client-supplied relations, bypassing the sensitive-relations
interceptor. It now asserts permissions and resolves through an allow-list, and
pins the employee to the caller unless they may change the selected employee.

GHSA-44pv-34gx-q9p4 (medium) residuals: validators that fell open on an undefined
key now fail closed, a DTO carrying both organization and organizationId
validates both instead of neither, and email-template listings and writes are
pinned to the caller's tenant plus the global templates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): close the review findings on the authz residuals

Review pass over PR #10242 (CodeRabbit, Greptile, SonarCloud). Two of the
findings were real gaps in the fixes this PR exists for.

@Roles() is not a gate on its own (CodeRabbit + Greptile, major)
  The repo-wide mutating-route scan accepted any @Roles() decorator as
  authorization. @Roles() only calls SetMetadata, and RoleGuard is NOT
  registered as an APP_GUARD (app.module.ts provides only the throttler),
  so a handler declaring roles without @UseGuards(RoleGuard) reads as
  role-gated while nothing ever consults those roles — exactly the kind of
  ungated mutating route the scan exists to catch. The scan now requires an
  effective RoleGuard, on the handler or on its controller, before it counts
  @Roles() as a gate; @Public() is unchanged. Every @Roles() in the tree
  today is already paired with @UseGuards(RoleGuard), so the ratchet lists
  are unchanged, and a new in-memory test pins both shapes.

An organization named as a bare id string skipped the employee check
  (CodeRabbit, major) EmployeeBelongsToOrganizationConstraint resolved the
  organization by reading `organization.id` only. A DTO that does not extend
  TenantOrganizationBaseDTO has no @IsObject() to refuse a string — e.g.
  EmployeeRecurringExpenseQueryDTO, which intersects EmployeeFeatureDTO
  alone, behind GET /employee-recurring-expense/month — so
  `?organization=<uuid>&employeeId=<foreign>` left the id unresolved and fell
  through to the deliberately permissive "no organization named" branch. Both
  shapes now resolve to the same value, so the membership lookup runs with
  the id the request actually named.

MikroORM findAll could read NULL-tenant rows of an organization (CodeRabbit)
  `tenantId: mTenantId ?? null` kept the tenant arm alive without a tenant in
  context, and MikroORM compiles a literal null to IS NULL, so the arm matched
  every NULL-tenant row rather than nothing. The tenant arm is now built only
  when there is a tenant, which is what scopeEmailTemplateWhere already did
  for the pagination route.

CreateEmailTemplateDTO declared only organizationId (CodeRabbit)
  name, languageCode and hbs are NOT NULL on email_template, so a create
  missing one could never persist; declaring them turns a database error into
  a 400 and lets Swagger publish the real create schema. The route now
  validates with whitelist, so an undeclared body key cannot reach
  persistence at all — stripEmailTemplateScopeFields stays as the explicit
  statement of which fields are scope fields.

Also the five new SonarCloud code smells: braces around the two switch cases
in EmailTemplateService.findAll (lexical declarations in a case block),
`IFindOneOptions<T> | unknown` collapsed to `unknown`, two useless `?? {}`
spreads, and toHaveLength in the opt-in-list assertion.

Refused: nothing. No finding asked for a protection to be weakened.

Tests: packages/core src/lib/{shared/validators,shared/guards,core/crud,
core/dto,email-template,organization-contact,employee-recurring-expense/dto}
-> 15 suites, 212 tests, all passing (was 13/191 plus one non-compiling suite).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): null-check request.query consistently in the guard control arm

DeepScan INSUFFICIENT_NULL_CHECK on
organization-permission.guard.spec.ts:677 — the CONTROL arm replays the
pre-fix `extractRequestOrganizationId` with optional chaining on
`request.query`, then asserted on `request.query.organizationId` without it.
The object is a local literal so nothing could have thrown, but the two
readings of the same expression disagreed. Both now use `?.`, which keeps
the replay a verbatim copy of the pre-fix production code.

No behaviour change; the suite still passes (45 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(email-template): validate languageCode against LanguagesEnum

Second CodeRabbit pass, on the DTO this PR just added.

- languageCode was `@IsString()`, so `xx` was accepted and persisted. Every
  reader of the column looks a template up by a LanguagesEnum value (the
  seeder, saveTemplate, the mailer) and the column has no enum constraint, so
  such a row is one nothing can ever find. Now `@IsEnum(LanguagesEnum)`, with
  a test for a rejected code and an accepted non-default one.
- The whitelist regression test passed vacuously: the fixture carried no
  tenant keys, so it would have stayed green even if the DTO later declared
  them. It now puts `tenant` and `tenantId` in the input, asserts they are on
  the transformed instance, runs the same whitelisting `validate()` the pipe
  runs, and asserts only the four template fields survive.

packages/core src/lib/email-template/dto -> 1 suite, 8 tests, passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(email-template): answer create with the stored row, not the request body

CodeQL flagged the create route as reflected XSS: it returned the body it had
just persisted. Not exploitable — the response is JSON and helmet sets
X-Content-Type-Options: nosniff — but echoing the request buys nothing. The
route now reads the row back through the tenant-scoped lookup, so the client
sees the scope fields the server pinned rather than the ones it sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.7
2026-09-20 16:25:18 +02:00
Ruslan KonviserandClaude Opus 5 2bc0b39cd0 fix(security): Nested-relation ownership checks (GHSA-jh6m, GHSA-gwpq, GHSA-6qvm) (#10238)
* fix(security): check ownership of nested relations, not just the root row

GHSA-jh6m-9fxr-rx3c (high): TenantAwareCrudService verified the tenant of the row
being written but not of the rows named inside it, so PUT /invoices/:id carrying
invoiceItems with another tenant's item id re-parented or overwrote that row, and
PUT /candidate/:id could cascade into another user's credentials. A new
assertGraphNotForeign() walks the payload's relation graph (depth-bounded, one
batched SELECT per relation), refuses foreign-tenant rows through any relation,
refuses re-parenting a global NULL-tenant row, stamps the caller's tenant on
cascaded inserts, and strips credential fields from a nested existing User.
Candidate updates no longer cascade into User at all.

Also closes the named siblings: request-approval's raw employee and team lookups
are tenant-scoped (GHSA-gwpq-mmw7-vx85); UpdateTimeSlotHandler, the bulk activity
save and the time-log routes are tenant-scoped, whitelisted and no longer accept
a foreign employeeId, and the organization policy guard checks the target's
tenant for SUPER_ADMIN too (GHSA-6qvm-3wg4-26w4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): link nested users instead of writing them, fail closed on the report queries

Review follow-up on the nested-relation ownership work. Six findings held up when
checked against the code; each protective line has a spec arm that fails when the
line is reverted.

- nested-graph: an EXISTING `User` reached through a cascade is now reduced to
  `{ id }`. Removing only the credential columns still let `user: { id, firstName }`
  rename any account of the caller's own tenant, which a tenant check cannot catch —
  the victim is a legitimate member of that tenant. A NEW user inserted through a
  cascade (candidate sign-up) is untouched. CandidateCreateHandler re-attaches the
  user it just created to its response, so POST /candidate still answers with the
  user the UI prints.

- nested-graph: a payload that would still persist rows BELOW the walk limit is now
  refused (400) instead of being waved through. The row sitting at the limit was
  checked, but its own relations were not walked, and `save()` would still cascade or
  re-parent them with no ownership check at all.

- user create: `UserCreateCommand` drops a body-supplied `id`. `CrudService.create()`
  upserts when the payload carries a primary key, so a body id turned "create a user"
  into "overwrite that user" — and POST /candidate / POST /employee hash the request's
  `password` into that payload, so `user: { id: <an admin of my tenant> }` reset that
  account's password and demoted its role. Same class as the candidate update cascade
  this branch already closed, through the create route instead.

- time logs / time slots: `getTimeLogs()`, the report queries built on
  `getFilterTimeLogQuery()` / `buildMikroOrmTimeLogWhere()` and `getTimeSlots()` build
  their own queries, so the never-matching employee condition added to the CRUD reads
  never applied to them. Their employee predicate is only added when `employeeIds` is
  non-empty, and `employeeIds` is only narrowed for a caller who HAS an employee
  record — so a caller with neither CHANGE_SELECTED_EMPLOYEE nor an employee read the
  whole organization, or the employees they named in the body. They now match nothing,
  exactly as `findConditionsWithoutOwnEmployee` already does.

- activities: `scopeActivitiesForWrite()` now drops a `projectId` / `taskId` (and
  folds a `project` / `task` object into its id) that does not name a row of the
  caller's tenant. These are plain foreign keys, so the nested-graph check never sees
  them, and the activity is written through the raw repository: a foreign projectId
  was stored verbatim and handed back with the victim tenant's project joined onto it.
  The bulk handler now applies its request-level `projectId` before that check rather
  than over it.

- OrganizationPermissionGuard: the SUPER_ADMIN exemption resolves the tenant before
  the no-target shortcut. Not exploitable today (TenantBaseGuard / TenantPermissionGuard
  already refuse a tenant-less request on both controllers that apply this guard), but
  the guard no longer depends on a sibling guard for it.

Also, for the two new SonarCloud complexity findings: the reference rules of
`assertGraphNotForeign` move into `resolveReference()` / `isGlobalRowWritable()`, and
`UpdateTimeSlotHandler.execute` into `resolveEmployeeScope()` / `collectChanges()` /
`saveActivities()`. Behaviour is unchanged; the existing specs cover both.

Two review findings were NOT applied, deliberately:
- scoping the request-approval employee / team lookups by
  `RequestContext.currentOrganizationId()`: cross-organization references inside one
  tenant are a different boundary (the advisories are about cross-tenant writes), and
  the header-derived organization is absent on legitimate calls, which would silently
  drop approvers.
- nothing was relaxed to silence a finding; no test was deleted or loosened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(time-tracking): drop the redundant optional chaining, keep a null employeeId meaning "no scope"

Follow-up on the review fixes, for DeepScan's three new findings on the previous
commit (all in the code it added).

- `user` is dereferenced unguarded a few lines above each of the new fail-closed
  guards, so `user?.employeeId` in them was a null check that can never fire; it now
  reads `user.employeeId` like the surrounding code (time-log report filters, both ORM
  branches, and `getTimeSlots`).

- `UpdateTimeSlotHandler.resolveEmployeeScope()` returned `ID | undefined | null`,
  where `null` meant "refuse" — but a caller holding CHANGE_SELECTED_EMPLOYEE may
  legitimately send `employeeId: null` (no employee filter), and that would have been
  read as a refusal. It now returns a scope object or `null`, so the two cases cannot
  be confused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(time-tracking): take the organization from the verified employee, not from the body

Two more review findings, both real: the manual-time routes and the bulk activity
save validated the employee against the caller's tenant but then persisted whatever
`organizationId` the body carried.

- `addManualTime` / `updateManualTime` already read the `futureDateAllowed` policy
  from `employee.organization`, so a body organizationId of the same tenant had the
  write judged by one organization's rules and then filed — time log, time slots and
  timesheet — under another's. On update it also decided which rows counted as
  conflicting, i.e. which sibling organization's time slots this call was allowed to
  delete. Both now derive the organization from the employee, with the body value left
  as a fallback for an employee that has none.

- `BulkActivitiesSaveHandler` used the employee's organization only when the body
  omitted one. An Employee row belongs to exactly one organization, so the body value
  could only ever file that employee's activities under an organization they are not a
  member of; the employee's own organization now wins.

Each is covered by a spec arm that fails when the line is reverted (verified by
revert-and-run, files restored and hashed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(core): split the per-relation walk and the activity normalisation out

SonarCloud's two remaining cognitive-complexity findings on this branch, both in code
it added. Pure extraction, no behaviour change:

- `assertGraphNotForeign()` keeps the level-by-level loop; the per-relation lookup and
  the per-reference rules move into `walkRelation()` (31 -> well under the threshold).
- `scopeActivitiesForWrite()` hands the relation-object stripping and the
  `project` / `task` fold to `normalizeReferences()`.

The nested-graph and activity specs (112 tests, real better-sqlite3 databases with
their CONTROL arms) cover both and stay green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(user): align the create handler with the merged role normalization

Merging develop brought in GHSA-x4mv's role handling: assertCanAssignRoles now
takes the payload and extracts every role form itself, and a role/roleId pair
naming two different roles is a 400. This spec still asserted the older
two-argument contract with a contradicting pair, so the two changes were green
apart and red together. It now covers both: the agreeing pair is validated, and
the contradicting pair is refused before anything is persisted.

Also replaces a stray console.log with the Nest logger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:21:09 +02:00
Ruslan KonviserandClaude Opus 5 3a62724c23 fix(security): Social-login audience verification + purpose-bound tokens (GHSA-58x4, GHSA-28wv) (#10241)
* fix(security): verify social-login token audience and bind every purpose token

GHSA-58x4-7mw9-gmqg (critical): POST /auth/signin.email.social accepted any
provider access token that resolved to a victim's email — another app's token or
a GitHub PAT — and signed the caller in as that user. Each provider is now
introspected against an allow-list of OAuth client ids (Google tokeninfo aud/azp
plus email_verified, GitHub /applications/{client_id}/token, Facebook
debug_token app_id) and fails closed when no client is configured. Twitter/X is
refused, since it exposes no verified email. One normaliser rejects an empty id
or email, so an undefined value can no longer reach a find() and be dropped by
TypeORM's undefined:'ignore' behaviour, which returned every user in every
tenant.

GHSA-28wv-vrxj-rp4q (medium): tokens signed with JWT_SECRET were interchangeable.
New signPurposeToken/verifyPurposeToken pin a purpose claim, required non-empty
claims and HS256. Workspace sign-in, invoice share, estimate, invite, team-join,
appointment and password-reset tokens are typed; public invoice and estimate
links are bound to the stored row and the URL id; access-token consumers
(JwtStrategy, RegisterAuthorizationGuard, Zapier, Plane) reject a token whose
purpose says it is something else. Untyped legacy tokens are accepted only where
they are also bound to a stored row, and never on signin.workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(auth): review follow-ups on the purpose-token hardening

Addresses the bot review round on PR #10241. No security behaviour is relaxed;
every change either tightens a check or is a readability fix with the same
runtime semantics, and the affected suites were re-run (9 suites / 155 tests).

- Password reset now goes through `verifyPurposeToken(TokenPurposeEnum.PASSWORD_RESET)`
  instead of a bare `verify()` plus a string-literal purpose comparison
  (CodeRabbit). It additionally requires a non-empty `id` claim: an `undefined`
  id reaching `findOneByIdString` widens the lookup instead of failing closed,
  which is exactly the class of bug this PR exists to remove. The stored
  password_reset row still binds the token, so this is defence in depth.
  `verify` and `JWT_ALGORITHMS` are no longer imported there.
- `JwtStrategy.validate` moves the employeeId/organizationId claim checks into
  `attachEmployeeAndOrganizationContext` (SonarCloud: cognitive complexity 16 >
  15). The helper RETURNS the UnauthorizedException instead of throwing, so the
  exact exceptions and messages the callback received before are unchanged, and
  three specs now cover the organization branch (member missing, employee in
  another organization, happy path). Control: inverting the rejection makes 12
  of the 30 tests in that suite fail.
- `normalizeSocialIdentity` extracts its nested ternary into
  `normalizeProviderAccountId` (SonarCloud), same accepted values as before:
  trimmed string, or a positive safe integer stringified for GitHub.
- `Number.NaN` over `NaN` in the reschedule-token lifetime (SonarCloud), and the
  two unused `catch (error)` bindings in PublicInvoiceService are now bare
  `catch`.

Not changed, deliberately: Greptile's P1 "mixed-case emails fail lookup". The
social lookup already queries BOTH the normalised (lowercased) address and the
provider's exact spelling, which is a strict superset of what this code did
before the PR, so nothing regressed. Matching stored emails case-insensitively
would let the holder of `a@x.com` sign into an account stored as `A@x.com` —
a widening of an authentication lookup that password login does not perform —
and belongs in a repo-wide email-normalisation change, not in this advisory fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.6
2026-09-20 16:20:57 +02:00
Ruslan KonviserandClaude Opus 5 7a4b82b621 fix(security): treat a missing employee identity as no access, not no filter
The tracked-data queries apply their employee predicate only when the id list is non-empty
(`if (isNotEmpty(employeeIds))`). `ManagedEmployeeService.filterAccessibleEmployeeIds` returned `[]`
for a caller with no employee identity, and the counts queries scope by hand with
`if (user.employeeId && ...)`, so such a caller got no predicate at all and read the whole
organization's tracked data: time slots with their screenshots, application and window titles,
manual times, members and per-project totals.

The token is self-serve. `POST /auth/switch-organization` needs CHANGE_SELECTED_ORGANIZATION, a
default EMPLOYEE permission; switching to an organization where the user has a user_organization row
but no employee record mints a token with `employeeId: null`, and `IsOrganizationBelongsToUser` then
still accepts the caller's own organization in the query. A user whose employee record was
soft-removed logs in the same way, since the User row stays active.

- `filterAccessibleEmployeeIds` and the hand-rolled scoping in `statistic.service.ts` now return
  `NO_ACCESSIBLE_EMPLOYEE_ID` (the nil UUID) for an authenticated caller with no employee identity:
  the predicate stays in place and matches nothing, so the answer is empty instead of everything.
- An organization-wide viewer (`ALL_ORG_VIEW`) keeps the access their role gives them.
- A request with no user at all — a public share link, an internal call — is left to the scoping its
  own caller applies, so `public-share` team pages are unaffected.
- The three hand-rolled sites now share one helper, which also removes the duplicated block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:08:38 +02:00
Ruslan Konviser 44321d6e8e Merge remote-tracking branch 'origin/develop' into fix/candidate-billing-rate-decimals 2026-09-20 15:51:32 +02:00
Ruslan KonviserandClaude Opus 5 7affd5ab71 fix(employee,candidate): cap rates, carry the minimum rate on hire, honor transformers under MikroORM
Follow-ups from review of this PR:
- @Max(999999999999.99) on all four rate fields, so a value the numeric(14,2) column cannot hold
  is a clean 400 from the DTO instead of a database overflow, and cannot block a rollback to the
  old integer column.
- CandidateHiredHandler now copies minimumBillingRate into the new employee. It only copied
  billRateValue, so a candidate's minimum rate was silently dropped on hire (bug, pre-existing).
- MikroORM ignored the TypeORM `transformer` column option: @MultiORMColumn handed it to @Property,
  which drops it, so with DB_ORM=mikro-orm money columns skipped rounding and validation and
  int-backed enum columns (actor type, availability status) were stored and read raw.
  parseMikroOrmColumnOptions now wraps the transformer in a MikroORM type and keeps the declared
  column DDL (numeric(14,2)).
- Specs for each, including the hire handler (new) and the transformer bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:50:48 +02:00
Ruslan KonviserandClaude Opus 5 c514654389 fix(core): stop reports 500ing when the request names no time zone
`moment().tz(undefined)` returns undefined instead of a moment, so every report that groups its rows
by `.tz(timeZone).format(...)` answered 500 "Cannot read properties of undefined (reading 'format')"
as soon as the organization had rows to group. `GET /timesheet/time-log/report/weekly` was found this
way while booting the API during the #10212 review; it fails for SUPER_ADMIN too.

- Add `resolveTimeZone()` next to `getDaysBetweenDates` and use it there, so the day list and the
  grouping keys of a report always come from one and the same zone. The fallback is the server zone,
  which is what `getDaysBetweenDates` and the group-by command handlers already defaulted to.
- Apply it to the five report bodies in `time-log.service.ts` (weekly, daily charts, daily, owed
  amount and its charts, time limit) and to `payment.service.ts`.
- Regression specs: every report method answers with no time zone and with an empty one, the rows
  land in a bucket the day list actually contains, and a named zone is still honoured. Without the
  fix these reproduce the exact production TypeError. `RecordingQueryBuilder` grew a `rows` field,
  because the report bodies only run over a non-empty result — which is why the existing filter
  specs never caught this.

Measured before and after against a booted API on a throwaway database: five routes (weekly,
daily-chart, owed-report, owed-charts, time-limit) went from 500 to 200 once the organization had a
time log; nothing else changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:36:50 +02:00
Ruslan KonviserandClaude Opus 5 b45fc7ee85 fix(security): MJML file-include LFI + integration-setting takeover (GHSA-48h9, GHSA-4rwq) (#10239)
* fix(security): compile MJML without file includes, lock down integration settings

GHSA-48h9-vwf5-h8m7 (critical): an email or accounting template containing
mj-include with a path was compiled with the file loader enabled, so any caller
who could preview or save a template could read files from the API container,
including .env and /proc/self/environ. All nine mjml2html call sites now go
through compileMjml(), which passes ignoreIncludes: true (verified against
mjml-parser-xml 4.18.0, which returns before any read) and coerces the source to
a string, so a JSON body can no longer arrive as a pre-parsed Handlebars AST. An
ESLint rule keeps raw mjml imports out of the package, and the preview routes
now validate their bodies.

GHSA-4rwq-65wh-45h4 (high): PUT /integration-setting/:id could rewrite
server-managed settings such as a GitHub installation_id, binding another
tenant's installation. Updates are now tenant-scoped, restricted to an allow-list
of user-editable keys, and pin settingsName and integrationId; the inherited
POST /integration-tenant route that reached the same sink is gated; and
installation_id is canonicalised so the uniqueness check compares like with like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): harden template source coercion, address review findings

Review follow-ups on the GHSA-48h9 / GHSA-4rwq fix. No security boundary
is relaxed; the coercion helper gets strictly stronger.

- toTemplateSource(): map every non-primitive (a JSON object or array, a
  Handlebars AST, a function) to '' instead of stringifying it. `String(value)`
  produced a useless '[object Object]' and, for an object whose `toString` and
  `valueOf` are not callable ({"toString":1,"valueOf":2} is valid JSON), threw a
  TypeError out of the request handler instead of rendering an empty template.
  The preview DTOs already reject a non-string `data`, so this is the second
  layer, and it is the layer the stored-`hbs` render path relies on. Primitives
  still stringify. Spec updated to assert the stronger outcome (the AST now
  renders to '', not to '[object Object]') and extended with the throwing shape.
  Also clears SonarCloud "'source ?? ''' will use Object's default
  stringification format".

- IntegrationTenantController.create(): replace the nested ternary that encoded
  "not an array means refuse" as a sentinel `[null]` element with an explicit
  guard. Same three outcomes (absent settings allowed, array checked
  element-wise, anything else forbidden), one fewer indirection. Clears
  SonarCloud "Extract this nested ternary operation".

- GITHUB_INSTALLATION_ID_PATTERN: `\d` for `[0-9]`. Identical in JS (`\d` is
  ASCII-only, with or without the `u` flag); clears a SonarCloud nitpick.

- integration-setting.utils.ts: record WHY the allowlist lookup stays on
  `Object.prototype.hasOwnProperty.call`. SonarCloud asks for `Object.hasOwn`,
  but that is ES2022 and packages/core/tsconfig.lib.json targets es2021 with no
  `lib` override, so it fails to compile (TS2550, verified with tsc).

Tests: packages/core email-template + accounting-template + integration-setting
+ integration-tenant = 8 suites, 123 tests passed; integration-github = 2 suites,
48 tests passed. ESLint over the changed core files: 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.5
2026-09-20 15:06:26 +02:00
Ruslan KonviserandClaude Opus 5 60eacd8a92 fix(security): Tenant-scoped invoice numbering (GHSA-57hw, GHSA-w3mx) (#10244)
* fix(security): scope the invoice number sequence to the tenant

GHSA-57hw-jqpj-ww97 (medium): GET /invoices/highest ran an unscoped MAX over
every tenant's invoices, so any authenticated user learned the highest invoice
number in the installation, and numbering leaked business volume across tenants.
The aggregate is now tenant-scoped in both ORM branches and fails closed without
a tenant. Because scoping alone would collide with numbers other tenants already
hold, the global unique on invoiceNumber becomes unique per (tenantId,
invoiceNumber), with a per-dialect migration for postgres, mysql and sqlite.

GHSA-w3mx-m5cr-3gxp residual (low): a tenant AI-provider credential with no
baseUrl still reached the provider's built-in loopback default. Every
tenant-sourced credential is now treated as tenant-controlled, so it is blocked
unless private base URLs are explicitly allowed; only an operator-set environment
base URL bypasses the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(invoice): make the MySQL uniqueness migration resumable and reject fractional numbers

Review follow-up on the tenant-scoped invoice numbering change.

MySQL commits every DDL statement on its own, so `migrationsTransactionMode: 'each'`
does not roll the CREATE and the DROP of `mysqlUpQueryRunner` back together. If the
process died between them — or the information_schema lookup failed — the new
composite index survived while the migration row was never written, and the retry on
the next boot died on `Duplicate key name`, taking the API down until someone repaired
the schema by hand. Both MySQL branches now look the index up by its COLUMNS first
(`GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)`, so column order is part of the
match and the primary key never matches), create only what is missing and drop every
same-shaped leftover whatever it is named. That also fixes the reverse case the
reviewers pointed out: `down()` used to drop only the index name this migration
happens to use.

`invoiceNumber` now requires a whole number. `numeric` (Postgres/SQLite) stores a
fraction that MySQL's `bigint` truncates, so a fractional number made
`MAX(invoiceNumber) + 1` mean different things per database; `@IsNumber()` accepted
it. `@IsInt()` already implies a number, so it replaces rather than joins `@IsNumber()`.

`up()`/`down()` dispatch through a table instead of the two switch statements every
migration in this folder repeats verbatim. The behaviour is identical (including the
`Unsupported database` throw); it is here because those ~40 boilerplate lines were
counted as duplicated new code and failed the Sonar quality gate for this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(invoice): cover the MySQL branch of the uniqueness migration

The MySQL resumability fix was argued from the MySQL manual, not exercised: no MySQL
server is available in this environment. It does not need one — the migration only ever
learns which unique indexes exist through `information_schema.STATISTICS`, so a stub
query runner over a simulated index set drives the real branch end to end and records
the DDL it issues.

Five cases, including the two the review raised: a crash between the CREATE and the
DROP (the index is found, no second CREATE is attempted, the old one is still dropped),
a completed run re-entered (no statements at all), an index the migration chain did not
name, and a revert that has to drop a composite index under an unexpected name. The
primary key shares the `NON_UNIQUE = 0` filter and is asserted to survive all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.4
2026-09-20 15:06:17 +02:00
Ruslan KonviserandClaude Opus 5 bf5aea9a7b fix(security): Role-reference normalization + credential leak (GHSA-x4mv, GHSA-hh83, GHSA-hjcg) (#10245)
* fix(security): resolve every form of a role reference before authorizing it

GHSA-x4mv-fhwj-g3rp (high): the role-change check read entity.role?.id and
entity.roleId, but TypeORM also accepts a relation given as a bare id string, so
a role sent as a plain SUPER_ADMIN uuid was invisible to the check and still
persisted — self-escalation through the profile update. extractRoleIds and
normalizeRolePayload now read every representation, reject a present but
unresolvable role, refuse a role/roleId pair naming two different roles, and
normalise the payload so the value that was checked is the value that is saved.
Wired into updateProfile, UserCreateHandler, the register handler, invites (which
previously stored a role the check never saw), employee and candidate creation,
and the DTO validator.

GHSA-hh83-hq74-gh9f (low): under DB_ORM=mikro-orm, wrap(entity).toJSON() ignores
class-transformer, so a populated User in a listing carried its credential
columns. refreshToken, code and codeExpireAt are hidden at the ORM level, and a
last-line scrub removes credential keys from User-shaped objects in responses.

GHSA-hjcg-633x-qq74 hardening: the register guard tenant-checks every role
identifier in the body instead of only the first one it finds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(invite): validate the role payload before branching on the inviter

`createBulk` only read the body's role references inside the fallback branch,
so the checks rode on the inviter's own role: an EMPLOYEE inviter, who is
force-assigned the EMPLOYEE role, had a malformed or self-contradicting payload
(`roleId` and `role` naming two different roles, `role: {}`, `roleId: ''`)
silently accepted, while every other inviter got a 400 for the same body.

That was never an escalation — the EMPLOYEE branch persists the checked role,
which is the point of GHSA-x4mv-fhwj-g3rp — but an answer that depends on who
is asking is a bad place to keep input validation, and it leaves the one caller
whose role is overridden as the only one whose payload nobody parses.

The extraction now runs once, before the branch, and a body naming two
different roles is refused for everyone. The fallback keeps its own
"exactly one" rule, since an invitation there cannot be issued without a role.
Regression coverage added for the EMPLOYEE inviter, plus a control that a body
with no role at all still issues an EMPLOYEE invitation.

Also splits `scrubNode()` out of `scrubUserCredentials()` so the walker stays
under SonarCloud's cognitive-complexity threshold (17 -> 9). No behaviour
change: the same nodes are visited and the same keys deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): use the US spelling in the new invite comment

Cspell flagged "licence" in the comment added by the previous commit; the
wording now avoids the word entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.3
2026-09-20 14:59:38 +02:00
Ruslan KonviserandClaude Opus 5 ebf676b2f8 fix(security): No published default signing secrets (GHSA-39j7, GHSA-chm8, GHSA-4r2r, GHSA-3cgp) (#10240)
* fix(security): never sign tokens with a published default secret

GHSA-39j7-x845-4w3c (high) and GHSA-chm8-2ggf-pgjq: when JWT_SECRET,
JWT_REFRESH_TOKEN_SECRET, JWT_VERIFICATION_TOKEN_SECRET or
EXPRESS_SESSION_SECRET was unset, the API fell back to a literal published in
this repository, so anyone could forge tokens and sessions. resolveSecret() now
substitutes a per-process random value instead, and the startup guard still
reports such a secret as unset, so production keeps refusing to boot. The
known-default list is shared by the config resolver, the startup guard and the
desktop apps, and it catches a default published for any key, not only its own.

Desktop apps generated no secrets at all: every install shipped the same baked
DESKTOP_JWT_* values while binding the local API to 0.0.0.0. They now provision
random per-install secrets on first run and rotate a stored published default.

DEMO=true keeps today's behaviour, with a TODO, pending a decision on
demo.gauzy.co's secrets.

Also: the MCP refresh grant re-checks the account on every refresh instead of
trusting a 30-day-old token (GHSA-3cgp-wmrg-4fqg residual), and the misleading
comment claiming the Electron seed-credential exemption is harmless is corrected
(GHSA-4r2r-mv32-3468).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(config): resolve signing secrets lazily so load order cannot split them

Found by a runtime probe against a locally built API: login returned 200 but the
access token it issued was rejected on the next request, because the process was
signing with one secret and verifying with another.

apps/api/src/main.ts calls loadEnv() (which reads .env.local and friends) only
after its imports have run, so @gauzy/config can be evaluated while JWT_SECRET is
still unset. With the published literal as the fallback that was invisible: the
early copy and any later copy both ended up on 'secretKey'. Once an unset secret
became a per-process random value, the early copy generated one while a copy
imported after loadEnv() read the configured value — so tokens signed by one
never verified in the other, and every authenticated request 401'd.

The four secrets are now getters on `environment` / `environment.prod` /
`defaultConfiguration.authOptions`, so each is read at first use, after the env
files are loaded. Adds the regression test for exactly that order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): separate an unavailable account lookup from a dead refresh token

Review pass over PR #10240. The substantive fixes:

- MCP refresh grant (CodeRabbit major, Greptile P1). `getMcpUserInfo` caught
  every error and answered `null`, so a database blip reached
  `refreshAccessToken` as "this user is gone" and the grant handler answered
  `invalid_grant`. Well-behaved OAuth clients discard a refresh token on that,
  so one transient outage signed active users out for good. `getMcpUserInfo`
  now re-throws a failed lookup, `refreshAccessToken` re-throws it as
  `UserLookupUnavailableError` (outside its own catch, so it cannot be
  flattened into `null`), and the grant answers `temporarily_unavailable` /
  503. `null` now means exactly one thing: the account can no longer sign in.
  The refresh token is still never revoked on either path.

- `resolveUser` is now REQUIRED (Greptile P2). It was optional on a public
  method, so a caller could keep the two-argument shape and silently skip the
  account check the parameter exists to perform. A missing resolver now throws
  before anything else (500 server_error), matching the missing-provider path.
  `UserLookupUnavailableError.is()` is used instead of bare `instanceof`: this
  package ships both as source and as a bundle, and a downlevelled
  `extends Error` would break the prototype chain and quietly restore the
  invalid_grant behaviour.

- Nested credentials reached the desktop logs (CodeRabbit major, CWE-532).
  `redactSecretsForLog` only looked at top-level keys, and `apps/desktop` logs
  the whole `DesktopSetupConfig`, so `postgres.dbPassword` and
  `secureProxy.ssl.key` were printed verbatim. It now walks nested objects and
  arrays, with a depth cap and cycle detection.

- The seeded-account warning missed renamed installs (CodeRabbit major).
  `getPublishedSeedAccounts()` read only today's `DEMO_*_EMAIL` values, but the
  database was seeded in the past: an operator who changed the address after
  installing still had `admin@ever.co` with the published password, and the
  check walked past it. Both the configured and the canonical addresses are now
  checked, deduplicated on the (email, password) pair.

- The bounded read could hide a vulnerable account (Greptile P2). One shared
  `IN (...)` query with a global limit of 10 let the rows of whichever address
  came back first use the whole budget. The budget is now per address (5 rows
  each), so every candidate is actually looked at, and rows are re-matched
  against the address they were fetched for.

- The lazy-getter commit (21e0bfd) broke its own regression suite:
  `validate-application-secrets.spec.ts` assigned to `environment.JWT_SECRET`,
  which is now getter-only, and all 8 tests died at "Cannot set property". The
  spec drives `process.env` instead, which is what the getters read, plus a new
  test pinning that a secret set AFTER import still decides the verdict.

Static-analysis cleanups: `config?.secret` next to an unconditional
`config.db` in both server launchers (the inconsistent null check DeepScan
flagged); the duplicated `JWT_VERIFICATION_TOKEN_SECRET` block in
.env.demo.compose; and the Sonar smells (`node:crypto`, assignment inside a
return, optional chaining, useless empty object).

Not applied: CodeRabbit's request to point the three k8s demo manifests and
fly.toml at a secret store. Those are the DEMO=true deployments this PR
deliberately holds; wiring `secretKeyRef` to Secrets that do not exist would
break the demo without changing the effective values, and the manifests already
document how to inject real ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(auth): correct a stale comment about the refresh account lookup

The deactivated-user test still said the wired provider answers `null` for a
transient lookup failure. It rejects now, and that case is covered by its own
test, so the comment described behaviour the suite no longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): drop a word cspell rejects from the token-manager comments

The Cspell check failed on "downlevelled" in two comments. Reworded to
"compiled for a pre-ES6 target", which says the same thing in words the
dictionary already has, rather than growing the project word list for prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(user): split the published-password check into named steps

The per-address rewrite pushed findAccountsUsingPasswords over SonarCloud's
cognitive-complexity limit (16/15) with three nested loops and an inline ORM
switch. The query and the verification loop are now their own methods, and the
candidates collapse into a Map of address -> passwords instead of being
filtered, de-duplicated and re-filtered inline. Same behaviour: one query per
distinct address, every proposed password tested, first match wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(core): report a non-exhaustive seed-password check instead of implying clean

The check reads a bounded number of rows per address, because it runs before
the API listens and every scrypt/bcrypt verification is expensive by design.
With the same seeded address in more tenants than the budget, a vulnerable row
can sit outside the sample — and a boot that found nothing looked exactly like
a boot that checked everything.

findAccountsUsingPasswords now returns `{ matches, inconclusive }`;
`inconclusive` names the addresses whose rows filled the budget without a
match, and boot prints a short note asking for those tenants to be audited
separately. It deliberately does NOT raise INSECURE ACCOUNTS in that case:
a scary warning on every boot of any large multi-tenant install is how a
warning gets ignored.

Raising the budget was the alternative and it does not fix anything — it only
moves the cliff, at the cost of boot time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:59:29 +02:00
Ruslan KonviserandClaude Opus 5 a472bfa248 fix(security): CSV formula injection + rate-limit residuals (GHSA-7xp5, GHSA-86mw) (#10243)
* fix(security): neutralize spreadsheet formulas in CSV exports

GHSA-7xp5-j564-4752 (medium): exported cells were written verbatim, so a stored
value beginning with = + - or @ executed as a formula when a colleague opened the
export in Excel or Sheets. A shared encoder in @gauzy/utils prefixes an
apostrophe to any cell starting with a formula trigger (including the full-width
forms), leaves strictly numeric values alone, and is reversed on import so an
export/import round-trip stays byte-exact. Every field is now quoted, which also
stops a bare CR inside a value from starting a new spreadsheet row, and the
invoice CSV in the web app gets real RFC 4180 quoting instead of JSON.stringify.

GHSA-86mw-2crg-vmhc residuals (low): the public invite routes are throttled, the
shipped compose files no longer trust a forwarded client IP while publishing the
API port directly, and RequestContext.currentIp() resolves the client address the
same way the throttler does instead of reading a spoofable header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(export-import): decode CSV cells only for archives we marked

The import side reversed the spreadsheet-formula escape on every parsed row,
but an uploaded ZIP is not necessarily one this server wrote: it can be a dump
from an older Gauzy, a filled-in `/export/template`, or a CSV set built by
external tooling. For those, un-escaping is data loss — a legitimate value such
as `'=notes` was persisted as `=notes`, silently. Both review bots flagged this
as the one thing blocking the merge, and they were right: the decoder had no way
to tell "we escaped this" from "somebody else wrote this".

A data export now carries a `gauzy-export.json` marker at the archive root
(format, version, `spreadsheetSafeCells`), written by `exportTables` and
`exportSpecificTables`. `ImportService` resolves that marker once per import and
decodes rows only when it is present and recognized; anything else is imported
byte for byte as it was parsed. `/export/template` is deliberately NOT marked —
an operator fills it in by hand, so nothing in it was ever escaped. The manifest
reader is defensive about an attacker-supplied file: missing, oversized,
malformed, a foreign format or a newer version all mean "do not decode".

The invoice/payment CSV builder no longer has a path that skips encoding: a
pre-joined header line used to be written through verbatim, and it was the only
value in the file that reached disk unquoted and un-neutralized. `buildCsv` and
`generateCsv` now declare `headers: string[]` (both callers already pass one),
and a stray string from an untyped caller is split and encoded rather than
trusted.

Tests: `import.service.spec.ts` imports the same escaped CSV with and without a
marker and asserts the apostrophe survives when unmarked (reverting the gate
fails those 5 tests); `export.service.spec.ts` asserts the data archive carries
the marker and the template archive does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.44.2
2026-09-20 14:59:19 +02:00
Ruslan KonviserandClaude Opus 5 11722b0f5a chore(cspell): add tinyint to the dictionary
The MySQL column type appears in comments and a spec added by #10212. Existing uses were only inside
packages/core/src/lib/database/migrations, which cspell ignores, so the word was never needed before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v111.44.1
2026-09-20 10:53:55 +02:00
Meet PrajapatiandMeet Prajapati cf438f50b8 feat: allow employees to see their tracked data by default (#9874) (#10212)
Adds an organization setting `allowEmployeeToSeeTrackedData` (default true, so existing organizations
keep today's behaviour) that lets an admin hide an employee's own tracked data — screenshots,
activity, app/URL history, time logs, videos and statistics — from that employee.

- `EmployeeTrackedDataGuard` enforces it on the read routes of the activity, custom-tracking,
  statistic, time-log and time-slot controllers and of the videos, camshot and soundshot plugins.
  It depends only on the global DataSource, so plugins can use it.
- Exempt: admins (`CHANGE_SELECTED_EMPLOYEE`), callers with no employee record in the tenant,
  team/project managers of the organization, and the routes recording depends on — `time-slot/:id`,
  `time-log/:id`, `time-log/conflict`, `POST statistics/tasks` (the desktop timer's task picker)
  and every write route. A spec pins that list.
- The setting is read from the caller's own organization plus any organization the request names, so
  a client-chosen `organizationId` cannot bypass it, and the employee identity is read from the
  database rather than the token claim.
- `GET /timesheet/statistics/tracked-data-access` lets the web UI hide navigation with the same rule;
  the menus, the activity tabs and the dashboard widgets follow it, managers included.
- Adds the admin toggle, seeds, the SQLite/PostgreSQL/MySQL migration and i18n for all 14 locales,
  and removes a UTF-8 BOM from 11 locale files that broke locale loading in the desktop apps.

Resolves #9874.

Co-authored-by: Meet Prajapati <meet987654@users.noreply.github.com>
v111.44.0
2026-09-20 10:49:02 +02:00
Ruslan KonviserandClaude Opus 5 0cc498b897 fix(candidate): keep billing rate cents instead of truncating to integers (#10235)
Follow-up to #10203, which fixed #10199 for employees only.

- candidate.billRateValue / minimumBillingRate become numeric(14,2) / decimal(14,2)
  (migration 1790000016000; 1790000015000 is taken by open PR #10212). Postgres and
  MySQL alter both columns in one statement, Postgres with a 5 s lock_timeout; SQLite
  copies through a temporary column. reWeeklyLimit stays integer.
- Candidate uses the same column options and toBillingRate transform as Employee,
  moved to shared/pipes/billing-rate.transform.ts so the two cannot drift.
- Fixes a regression on develop: PUT /candidate/:id validates rates with the employee
  UpdateProfileDTO, which keeps cents since #10203, so Postgres rejected 10.49 in the
  integer candidate column (400) and MySQL rounded 10.5 to 11.
- ColumnNumericTransformerPipe(scale).to() stores a blank string as NULL instead of
  returning 400 (routes that skip DTO validation, e.g. POST /candidate and /employee).
- Specs: candidate transforms, UpdateCandidateDTO request path, response serialization,
  migration SQL for all engines, blank-string handling.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.43.7
2026-09-20 01:22:30 +02:00
Ruslan KonviserandClaude Opus 5 4c6e7c7fda fix(security): stop logging the Redis password and other credentials at boot (#10234)
The API printed the full REDIS_URL - Valkey password included - to stdout at boot (Redis health indicator and Redis session store). Boot-time log lines now go through dependency-free helpers in core/util/redact-credentials.ts that keep scheme/user/host/port and replace credentials with ***:

- REDIS_URL lines (health indicator, session store): redis://:***@host:port
- Malformed REDIS_URL: the ERR_INVALID_URL error (which carries the URL on error.input) is redacted before console.error; a partially redacted error is never returned
- tracer: Honeycomb API key presence only; OTEL_EXPORTER_OTLP_HEADERS names only; tracing URL redacted
- Unleash config line: customHeaders by name only, URL credentials redacted

The Redis clients, exporters and Unleash still receive the real credentials; only log output changes. Specs cover every path with sentinel values and positive controls, and each was confirmed red against the previous code. No credential rotated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v111.43.6
2026-09-19 23:31:00 +02:00
Ruslan KonviserandClaude Opus 5 ca19d325c0 fix(candidate): keep billing rate cents instead of truncating to integers
Follow-up to #10203, which fixed #10199 for employees only.

- candidate.billRateValue / minimumBillingRate become numeric(14,2) / decimal(14,2)
  (migration 1790000016000; 1790000015000 is taken by open PR #10212). Postgres and
  MySQL alter both columns in one statement, Postgres with a 5 s lock_timeout; SQLite
  copies through a temporary column. reWeeklyLimit stays integer.
- Candidate uses the same column options and toBillingRate transform as Employee,
  moved to shared/pipes/billing-rate.transform.ts so the two cannot drift.
- Fixes a regression on develop: PUT /candidate/:id validates rates with the employee
  UpdateProfileDTO, which keeps cents since #10203, so Postgres rejected 10.49 in the
  integer candidate column (400) and MySQL rounded 10.5 to 11.
- ColumnNumericTransformerPipe(scale).to() stores a blank string as NULL instead of
  returning 400 (routes that skip DTO validation, e.g. POST /candidate and /employee).
- Specs: candidate transforms, UpdateCandidateDTO request path, response serialization,
  migration SQL for all engines, blank-string handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:33:44 +02:00
55eff103a0 fix(employee): keep billing rate cents instead of truncating to integers (#10203)
* fix(employee): keep billing rate cents instead of truncating to integers

parseInt dropped decimals on billRateValue and minimumBillingRate, so 10.49 was stored as 10. Persist those money columns as numeric(10,2) and round to two decimal places. Weekly hour limits stay integer.

Fixes #10199

* fix(employee): round billing rates and SQLite rollback to integers

Address PR review: persist two-decimal money in the TypeORM transformer, use half-up rounding instead of toFixed, CAST ROUND on SQLite down, and assert ADD/UPDATE/DROP/RENAME order.

* fix(employee): keep the full old rate range and keep rejecting non-numeric rates

- numeric(10,2)/decimal(10,2) topped out at 99,999,999.99 while the old int
  columns held up to 2,147,483,647; use (14,2), as the payroll money columns
  do, so no value that saved before is rejected and the ALTER cannot overflow
  on existing rows
- toBillingRate turned any non-numeric input into 0, so @IsNumber never
  failed; parse with parseFloat and return NaN for non-finite values so
  'abc', true, {} and 'Infinity' are rejected again and '10,50' keeps 10
- roundToScale returned NaN for numbers printed in exponent form (1e-7)
- specs for all three; prettier on the new migration

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(employee): refuse non-numeric rates instead of storing 0, round like the database

- ColumnNumericTransformerPipe(scale).to() now parses strings like the DTO transform and throws
  400 for anything that is not a finite number. POST /employee and /employee/bulk do not validate
  billRateValue / minimumBillingRate, so 'abc', '', true or {} used to be stored as 0 or 1 where the
  old integer column rejected them.
- roundToScale rounds half away from zero (-1.005 -> -1.01), as Postgres numeric and MySQL decimal
  do; returns NaN rather than 0 for non-numeric input; and leaves doubles too large to hold cents
  unchanged instead of drifting or overflowing to Infinity.
- Migration: Postgres and MySQL alter both columns in one statement (one table rewrite on Postgres,
  no half-applied state on MySQL), and Postgres sets a 5 s lock_timeout so a long transaction on
  employee cannot queue every request behind the ALTER.
- Public-page employee form: bill rate step 0.1 -> 0.01, matching the rates form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(employee): type the rate transformer input as unknown, clear static-analysis warnings

- ColumnNumericTransformerPipe.to() takes `unknown`: routes that skip DTO validation pass raw
  request values through, so the string branch is reachable (DeepScan CONSTANT_CONDITION)
- parsing moved into parseNumeric(); Number.parseFloat / Number.NaN (SonarCloud)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Ruslan Konviser <evereq@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v111.43.5
2026-09-19 16:09:54 +02:00
joel kalema 33d464dba1 Merge pull request #10227 from joel-kalema/fix/polish-goals-pages
Fix/polish goals pages
v111.43.4
2026-09-19 14:23:53 +02:00
Ruslan Konviser 85e69c2236 Merge pull request #10231 from Ahson-Shaikh/add-easypanel-deploy-docs
Add Easypanel deployment option
v111.43.3
2026-09-19 09:55:42 +02:00
Ahson Shaikh 908f2fc004 Add Easypanel deployment option 2026-09-19 00:28:13 +05:00
Ruslan Konviser 24c6a4dbe2 Merge pull request #10225 from heykav/fix/is-class-instance-null-prototype
fix(utils): isClassInstance throws on a null-prototype object
v111.43.2
2026-09-18 02:23:38 +02:00
Ruslan Konviser 624355f8b7 fix(utils): don't throw when constructor itself is null or undefined
`{"constructor": null}` is valid JSON, so any parsed payload can carry an own
`constructor` that is null or undefined. Reading `.name` on it throws exactly
like the null-prototype case this branch fixes, and the crash is reachable from
real input: `parseObject`, `deepClone` and `deepMerge` all call
`isClassInstance`, and `{"constructor":null}` survives `JSON.parse` intact.

Read the constructor into a local first and treat null/undefined as plain data.
Behaviour is unchanged for every input that did not already throw - checked
against the pre-fix implementation across 27 object shapes (plain literals,
null-prototype objects, class instances, Date/Map/Set/RegExp, arrays,
primitives, nested values); only the previously-throwing shapes changed, and
all of them now answer `false`.
2026-09-18 02:21:38 +02:00
Ruslan Konviser 6f547325e9 Merge pull request #10226 from ever-co/chore/cspell-dedup-words
chore(cspell): add deduped, dedups, resaved and upsert
v111.43.1
2026-09-18 00:43:19 +02:00
Ruslan KonviserandClaude Opus 5 e51dc49ba0 chore(cspell): add deduped, dedups, resaved and upsert
The cspell job went red on develop after #10198 (run 35277570001, 9 issues
in 5 files). All four are inflections of words the list already carries
(dedup, resave, upserted/upserts). All 5 flagged files now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:40:39 +02:00
Trapa-EurekaandClaude Opus 5 d810c81cf3 feat(core): persistence invariants, config/boundary validation, migration smoke test and correlation IDs (#10198)
Adds test harnesses for tenant isolation, TypeORM/MikroORM parity, persistence invariants and idempotency; startup validation for DB config; Nx plugin/core boundary rules; a fresh-database migration smoke test; and a correlation id carried from HTTP requests into docs queue jobs and logs.

Testing (packages/core/src/lib/core/testing):
- tenant-isolation: an in-memory tenant-aware repository, fixtures and strict assertions (a non-empty page, every row in the caller's tenant). Applied to EmployeeService and OrganizationProjectService.
- orm-conformance and persistence-invariants: suites that run the same checks under TypeORM and MikroORM (run-both-orms.sh uses yarn nx).
- idempotency: assertion helpers and specs for token cleanup (positive control), employee notifications and the Zapier timer webhook.
- database/migration-smoke.spec.ts: runs the full migration chain on a fresh better-sqlite3 database. It is excluded from the default core jest run; run it with `nx run core:test-migration-smoke`.

Idempotency:
- EmployeeNotificationService.create() takes an opt-in `absorbRedelivery`. With it set, an identical unread, unarchived notification under 60 s old (same receiver, entity, type, sender, title and message) is returned instead of inserted, and a missing key or a failed lookup inserts as usual. The event handler does not enable it (the in-process EventBus never redelivers), so every caller inserts one row per event as before.
- ZapierWebhookService skips resending a webhook that already succeeded for the same subscription, action and time log within 5 minutes. The key is reserved while in flight, failed deliveries stay retryable, pruning stops at the first unexpired entry, and the cache is capped at 10,000 entries.

Config (packages/config):
- An unknown DB_TYPE fails fast with the list of supported values; an empty DB_TYPE still means better-sqlite3; mongodb throws an Error.
- Pool and timeout variables are parsed with Number.parseInt semantics, only for postgres and mysql. They throw only where tarn already refused to start and warn otherwise. SQLite ignores them and still prints the startup values.

Observability:
- RequestContextMiddleware accepts an inbound x-correlation-id of 1-128 visible ASCII characters (otherwise it generates a UUIDv4) and echoes it on the response. CORS allows and exposes X-Correlation-Id. RequestContext.currentCorrelationId() is added.
- The docs queue carries the correlation id through job payloads (including bulk reindex) into pipeline outcome, error, dead-letter and enqueue-failure logs.

Boundaries and build:
- Every project gets a type tag. The ESLint depConstraints stop type:core depending on plugins, and plugins may depend only on core, shared libs and the known extension points (ai-chat, job-proposal, integration-ai, job-*-ui).
- core declares @gauzy/scheduler (package.json and implicitDependencies); the webapp Dockerfile copies scheduler's package.json.
- The integration-zapier jest config can import @gauzy/core (transformIgnorePatterns, allowJs, isolatedModules).
- Fixes the stale @nrwl/nx eslint-disable id in the e2e roles-permissions steps and the broken @gauzy/core mock in the docs document-scope spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v111.43.0
2026-09-17 23:35:52 +02:00
Krishna Anubhav ( Kavy ) f434f92c3b test(utils): cover isClassInstance/deepClone/deepMerge with null-prototype objects 2026-09-18 01:30:22 +05:30
Krishna Anubhav ( Kavy ) ec96bafdc7 fix(utils): isClassInstance throws on a null-prototype object 2026-09-18 01:30:21 +05:30
joel kalema da19222a45 Feat/polish timesheet details (#10221)
* style(timesheet): size the page title and breadcrumb at 12px

* fix(timesheet): let each tab's card fill the page down to the footer

* style(timesheet): tighten the filter row on the Calendar, Weekly and Daily tabs

* style(timesheet): set all text on the Calendar tab to 12px

* fix(timesheet): stop hour rows showing through the calendar's sticky day header

* feat(timesheet): open the Daily selection actions in the filter row

* style(timesheet): set Daily row text to 12px

* refactor(timesheet): move the View page's inline styles into classes and drop a debug log

* fix(timesheet): align View page rows with their header and mark the selected log

* feat(time-log): lay out the Edit Time Log form on one shared grid

* fix(time-log): return to the View Time Log popup when the edit is cancelled

* fix(time-log): fit the View Time Log pane to its card so the backdrop closes it

* feat(time-log): redesign the View Time Logs popover with a header, aligned rows and a footer

* fix(timesheet): cap the Weekly tab's text at 12px like the Calendar tab

* feat(timesheet): redesign the screenshot slot popup with a header, summary strip and aligned time logs

* fix(timesheet): give the slot popup's screenshots an employee id so the viewer opens on the clicked one

* feat(timesheet): move the slot popup's summary into its header and put apps beside their title

* style(timesheet): fit the slot popup without a scrollbar and match its thumbnails and bar to the Recent Activities card

* fix(gallery): open the screenshot viewer without focusing its first button

* feat(gallery): redesign the screenshot viewer with a top bar, round nav buttons and a filmstrip

* style(gallery): darken and blur the viewer backdrop and dim missing-screenshot placeholders

* feat(gallery): sort the viewer by time, step with arrow keys and keep the active thumbnail in view

* feat(i18n): add screenshot labels that name each screenshot by its time or place in the set

* fix(timesheet): give the slot popup's screenshots alt text and name their info button

* fix(timesheet): let the Time Logs popover's time range wrap on narrow screens

* fix(gallery): name the viewer's info button, image and thumbnails and mark the selected thumbnail

* fix(gallery): scroll the filmstrip without animation when reduced motion is on
v111.42.17
2026-09-17 19:04:04 +02:00
Ruslan Konviser 73e2318a7b Merge pull request #10222 from ever-co/fix/desktop-apps-missing-style-tokens
fix(desktop): mirror ui-core style tokens in desktop apps' shadowing SCSS copies (build-desktop red since 09-13)
v111.42.16
2026-09-17 18:16:42 +02:00
Ruslan KonviserandClaude Opus 5 abd838e202 fix(desktop): mirror ui-core style tokens in desktop apps' shadowing SCSS copies
The desktop-side Angular apps (desktop, desktop-timer, server, server-api,
agent) put apps/<app>/src/assets/styles ahead of
packages/ui-core/static/styles in stylePreprocessorOptions.includePaths.
Their own var.scss and gauzy/_gauzy-overrides.scss therefore shadow the
ui-core files for every bare `@use 'var'` / `@use 'gauzy/_gauzy-overrides'`
in shared ui-core stylesheets, and tokens added only to ui-core are
undefined in these builds:

- $tabset-dsk-tab-padding-block / -inline (#10160), read by
  includes/_tabset.scss and gauzy/_tabset-actions.scss
- $control-hairline / $control-padding-x (#9857), read by
  time-tracker.component.scss since #10207

This has failed build-desktop on develop since c53db3d7c0.

Add these four tokens, with ui-core's exact values, to each app copy and
note why they are mirrored. Additive only: no existing value changes.
A full SCSS sweep using the Angular pipeline's Sass and resolver (2978
compiles) now has zero errors for all five apps. Every stylesheet that
compiled before produces byte-identical CSS. apps/gauzy is unaffected
because its include path never reaches these files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:42:35 +02:00
Ruslan Konviser 24ff0e3db0 Merge pull request #10219 from ever-co/chore/cspell-dotfile-words
chore: add dotfile, dotfiles and unmocked to the spell-check dictionary
v111.42.14
2026-09-17 12:32:14 +02:00
Ruslan Konviser 8c145d3171 Merge pull request #10195 from ever-co/fix/ghsa-mggh-w3mx-webhook-and-ssrf
Fix: verify GitHub webhook signatures and guard AI-provider base URLs against SSRF
v111.42.13
2026-09-17 12:31:54 +02:00
Ruslan KonviserandClaude Opus 5 9c866c72e1 fix(security): cover reserved address ranges and harden the connect-time lookup
Three follow-ups from the independent verification of the connection-level
SSRF guard.

The private-address predicate in @gauzy/utils treated several special-purpose
ranges as public: benchmarking space 198.18.0.0/15 (a common cluster CIDR),
the IETF and documentation ranges, multicast, 240.0.0.0/4, deprecated
site-local fec0::/10, the NAT64 local-use prefix 64:ff9b:1::/48 and the
deprecated IPv4-compatible ::a.b.c.d form, so [::7f00:1] passed the literal
check. They are now refused. The shared predicate is also used by the core
SSRF agent and the Make.com, Zapier and Ever Async integrations; none of their
legitimate destinations sits in these ranges.

The validating lookup's promise chain now ends in a catch. If Node's connect
callback ever threw synchronously, the throw would otherwise become an
unhandled rejection, which terminates the process on Node 24.

An empty constructor in a spec mock tripped the no-empty-function lint rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:31:24 +02:00
Ruslan KonviserandClaude Opus 5 1d6f28cec0 test(ai-chat): cover connection-level bypasses of the AI-provider SSRF guard
Add ssrf-connection-bypass.spec.ts. It runs against a real HTTP server on
127.0.0.1 that a bypass would actually reach, and covers:

- The default dns.lookup path, with no injected resolver. A name that
  answers public, or "no such host", to the pre-flight and loopback at
  connect time is refused. localhost as the OS resolves it is refused, and
  an operator endpoint on localhost still connects.
- IP literals that Node connects to without calling the lookup: decimal,
  hexadecimal, octal, shortened, IPv4-mapped, the unspecified IPv6 address
  and 0.0.0.0. Each is refused before a socket opens.
- An HTTPS request resolves through the lookup too.
- A Happy Eyeballs answer set that hides one private address among public
  ones is refused as a whole.
- A keep-alive server gets a new connection, and a new lookup, for every
  request.
- Hostile upstream behaviour raises no unhandled error: a reset before the
  headers, during the body or of a body nobody reads, a corrupt gzip body,
  an abort with the body unread, and a streamed upload refused at connect.

Each group fails when its guard is broken (lookup check off, first address
only, literal check off, pooled agent, no request error listener). The
existing suites stayed green with a pooled agent and with the default
connection resolver bypassed, so those two properties had no coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:13:53 +02:00