Merge pull request #883 from Wikid82/feature/beta-release
feat: add support for Ntfy notification provider
This commit is contained in:
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -23,7 +23,7 @@ name: Docker Build, Publish & Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, development]
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Docker Lint"]
|
||||
@@ -42,7 +42,7 @@ env:
|
||||
TRIGGER_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
TRIGGER_REF: ${{ github.event_name == 'workflow_run' && format('refs/heads/{0}', github.event.workflow_run.head_branch) || github.ref }}
|
||||
TRIGGER_HEAD_REF: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref }}
|
||||
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && join(github.event.workflow_run.pull_requests.*.number, '') || github.event.pull_request.number }}
|
||||
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && join(github.event.workflow_run.pull_requests.*.number, '') || format('{0}', github.event.pull_request.number) }}
|
||||
TRIGGER_ACTOR: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.actor.login || github.actor }}
|
||||
|
||||
jobs:
|
||||
|
||||
232
.grype.yaml
232
.grype.yaml
@@ -4,83 +4,6 @@
|
||||
# Documentation: https://github.com/anchore/grype#specifying-matches-to-ignore
|
||||
|
||||
ignore:
|
||||
# GHSA-69x3-g4r3-p962 / CVE-2026-25793: Nebula ECDSA Signature Malleability
|
||||
# Severity: HIGH (CVSS 8.1)
|
||||
# Package: github.com/slackhq/nebula v1.9.7 (embedded in /usr/bin/caddy)
|
||||
# Status: Cannot upgrade — smallstep/certificates v0.30.0-rc2 still pins nebula v1.9.x
|
||||
#
|
||||
# Vulnerability Details:
|
||||
# - ECDSA signature malleability allows bypassing certificate blocklists
|
||||
# - Attacker can forge alternate valid P256 ECDSA signatures for revoked
|
||||
# certificates (CVSSv3: AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N)
|
||||
# - Only affects configurations using Nebula-based certificate authorities
|
||||
# (non-default and uncommon in Charon deployments)
|
||||
#
|
||||
# Root Cause (Compile-Time Dependency Lock):
|
||||
# - Caddy is built with caddy-security plugin, which transitively requires
|
||||
# github.com/smallstep/certificates. That package pins nebula v1.9.x.
|
||||
# - Checked: smallstep/certificates v0.27.5 → v0.30.0-rc2 all require nebula v1.9.4–v1.9.7.
|
||||
# The nebula v1.10 API removal breaks compilation in the
|
||||
# authority/provisioner package; xcaddy build fails with upgrade attempted.
|
||||
# - Dockerfile caddy-builder stage pins nebula@v1.9.7 (Renovate tracked) with
|
||||
# an inline comment explaining the constraint (Dockerfile line 247).
|
||||
# - Fix path: once smallstep/certificates releases a version requiring
|
||||
# nebula v1.10+, remove the pin and this suppression simultaneously.
|
||||
#
|
||||
# Risk Assessment: ACCEPTED (Low exploitability in Charon context)
|
||||
# - Charon uses standard ACME/Let's Encrypt TLS; Nebula VPN PKI is not
|
||||
# enabled by default and rarely configured in Charon deployments.
|
||||
# - Exploiting this requires a valid certificate sharing the same issuer as
|
||||
# a revoked one — an uncommon and targeted attack scenario.
|
||||
# - Container-level isolation reduces the attack surface further.
|
||||
#
|
||||
# Mitigation (active while suppression is in effect):
|
||||
# - Monitor smallstep/certificates releases at https://github.com/smallstep/certificates/releases
|
||||
# - Weekly CI security rebuild flags any new CVEs in the full image.
|
||||
# - Renovate annotation in Dockerfile (datasource=go depName=github.com/slackhq/nebula)
|
||||
# will surface the pin for review when xcaddy build becomes compatible.
|
||||
#
|
||||
# Review:
|
||||
# - Reviewed 2026-02-19: smallstep/certificates latest stable remains v0.27.5;
|
||||
# no release requiring nebula v1.10+ has shipped. Suppression extended 14 days.
|
||||
# - Reviewed 2026-03-13: smallstep/certificates stable still v0.27.5, extended 30 days.
|
||||
# - Next review: 2026-04-12. Remove suppression immediately once upstream fixes.
|
||||
#
|
||||
# Removal Criteria:
|
||||
# - smallstep/certificates releases a stable version requiring nebula v1.10+
|
||||
# - Update Dockerfile caddy-builder patch to use the new versions
|
||||
# - Rebuild image, run security scan, confirm suppression no longer needed
|
||||
# - Remove both this entry and the corresponding .trivyignore entry
|
||||
#
|
||||
# References:
|
||||
# - GHSA: https://github.com/advisories/GHSA-69x3-g4r3-p962
|
||||
# - CVE-2026-25793: https://nvd.nist.gov/vuln/detail/CVE-2026-25793
|
||||
# - smallstep/certificates: https://github.com/smallstep/certificates/releases
|
||||
# - Dockerfile pin: caddy-builder stage, line ~247 (go get nebula@v1.9.7)
|
||||
- vulnerability: GHSA-69x3-g4r3-p962
|
||||
package:
|
||||
name: github.com/slackhq/nebula
|
||||
version: "v1.9.7"
|
||||
type: go-module
|
||||
reason: |
|
||||
HIGH — ECDSA signature malleability in nebula v1.9.7 embedded in /usr/bin/caddy.
|
||||
Cannot upgrade: smallstep/certificates v0.27.5 (latest stable as of 2026-03-13)
|
||||
still requires nebula v1.9.x (verified across v0.27.5–v0.30.0-rc2). Charon does
|
||||
not use Nebula VPN PKI by default. Risk accepted pending upstream smallstep fix.
|
||||
Reviewed 2026-03-13: smallstep/certificates stable still v0.27.5, extended 30 days.
|
||||
expiry: "2026-04-12" # Re-evaluated 2026-03-13: smallstep/certificates stable still v0.27.5, extended 30 days.
|
||||
|
||||
# Action items when this suppression expires:
|
||||
# 1. Check smallstep/certificates releases: https://github.com/smallstep/certificates/releases
|
||||
# 2. If a stable version requires nebula v1.10+:
|
||||
# a. Update Dockerfile caddy-builder: remove the `go get nebula@v1.9.7` pin
|
||||
# b. Optionally bump smallstep/certificates to the new version
|
||||
# c. Rebuild Docker image and verify no compile failures
|
||||
# d. Re-run local security-scan-docker-image and confirm clean result
|
||||
# e. Remove this suppression entry
|
||||
# 3. If no fix yet: Extend expiry by 14 days and document justification
|
||||
# 4. If extended 3+ times: Open upstream issue on smallstep/certificates
|
||||
|
||||
# CVE-2026-2673: OpenSSL TLS 1.3 server key exchange group downgrade
|
||||
# Severity: HIGH (CVSS 7.5)
|
||||
# Packages: libcrypto3 3.5.5-r0 and libssl3 3.5.5-r0 (Alpine apk)
|
||||
@@ -153,161 +76,6 @@ ignore:
|
||||
Risk accepted pending Alpine upstream patch.
|
||||
expiry: "2026-04-18" # Initial 30-day review period. See libcrypto3 entry above for action items.
|
||||
|
||||
# CVE-2026-33186 / GHSA-p77j-4mvh-x3m3: gRPC-Go authorization bypass via missing leading slash
|
||||
# Severity: CRITICAL (CVSS 9.1)
|
||||
# Package: google.golang.org/grpc v1.74.2 (embedded in /usr/local/bin/crowdsec and /usr/local/bin/cscli)
|
||||
# Status: Fix available at v1.79.3 — waiting on CrowdSec upstream to release with patched grpc
|
||||
#
|
||||
# Vulnerability Details:
|
||||
# - gRPC-Go server path-based authorization (grpc/authz) fails to match deny rules when
|
||||
# the HTTP/2 :path pseudo-header is missing its leading slash (e.g., "Service/Method"
|
||||
# instead of "/Service/Method"), allowing a fallback allow-rule to grant access instead.
|
||||
# - CVSSv3: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
|
||||
#
|
||||
# Root Cause (Third-Party Binary):
|
||||
# - Charon's own grpc dependency is patched to v1.79.3 (updated 2026-03-19).
|
||||
# - CrowdSec ships grpc v1.74.2 compiled into its binary; Charon has no control over this.
|
||||
# - This is a server-side vulnerability. CrowdSec uses grpc as a server; Charon uses it
|
||||
# only as a client (via the Docker SDK). CrowdSec's internal grpc server is not exposed
|
||||
# to external traffic in a standard Charon deployment.
|
||||
# - Fix path: once CrowdSec releases a version built with grpc >= v1.79.3, rebuild the
|
||||
# Docker image (Renovate tracks the CrowdSec version) and remove this suppression.
|
||||
#
|
||||
# Risk Assessment: ACCEPTED (Constrained exploitability in Charon context)
|
||||
# - The vulnerable code path requires an attacker to reach CrowdSec's internal grpc server,
|
||||
# which is bound to localhost/internal interfaces in the Charon container network.
|
||||
# - Container-level isolation (no exposed grpc port) significantly limits exposure.
|
||||
# - Charon does not configure grpc/authz deny rules on CrowdSec's server.
|
||||
#
|
||||
# Mitigation (active while suppression is in effect):
|
||||
# - Monitor CrowdSec releases: https://github.com/crowdsecurity/crowdsec/releases
|
||||
# - Weekly CI security rebuild flags the moment a fixed CrowdSec image ships.
|
||||
#
|
||||
# Review:
|
||||
# - Reviewed 2026-03-19 (initial suppression): grpc v1.79.3 fix exists; CrowdSec has not
|
||||
# yet shipped an updated release. Suppression set for 14-day review given fix availability.
|
||||
# - Next review: 2026-04-02. Remove suppression once CrowdSec ships with grpc >= v1.79.3.
|
||||
#
|
||||
# Removal Criteria:
|
||||
# - CrowdSec releases a version built with google.golang.org/grpc >= v1.79.3
|
||||
# - Rebuild Docker image, run security-scan-docker-image, confirm finding is resolved
|
||||
# - Remove this entry and the corresponding .trivyignore entry simultaneously
|
||||
#
|
||||
# References:
|
||||
# - GHSA-p77j-4mvh-x3m3: https://github.com/advisories/GHSA-p77j-4mvh-x3m3
|
||||
# - CVE-2026-33186: https://nvd.nist.gov/vuln/detail/CVE-2026-33186
|
||||
# - grpc fix (v1.79.3): https://github.com/grpc/grpc-go/releases/tag/v1.79.3
|
||||
# - CrowdSec releases: https://github.com/crowdsecurity/crowdsec/releases
|
||||
- vulnerability: CVE-2026-33186
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
version: "v1.74.2"
|
||||
type: go-module
|
||||
reason: |
|
||||
CRITICAL — gRPC-Go authorization bypass in grpc v1.74.2 embedded in /usr/local/bin/crowdsec
|
||||
and /usr/local/bin/cscli. Fix available at v1.79.3 (Charon's own dep is patched); waiting
|
||||
on CrowdSec upstream to release with patched grpc. CrowdSec's grpc server is not exposed
|
||||
externally in a standard Charon deployment. Risk accepted pending CrowdSec upstream fix.
|
||||
Reviewed 2026-03-19: CrowdSec has not yet released with grpc >= v1.79.3.
|
||||
expiry: "2026-04-02" # 14-day review: fix exists at v1.79.3; check CrowdSec releases.
|
||||
|
||||
# Action items when this suppression expires:
|
||||
# 1. Check CrowdSec releases: https://github.com/crowdsecurity/crowdsec/releases
|
||||
# 2. If CrowdSec ships with grpc >= v1.79.3:
|
||||
# a. Renovate should auto-PR the new CrowdSec version in the Dockerfile
|
||||
# b. Merge the Renovate PR, rebuild Docker image
|
||||
# c. Run local security-scan-docker-image and confirm grpc v1.74.2 is gone
|
||||
# d. Remove this suppression entry and the corresponding .trivyignore entry
|
||||
# 3. If no fix yet: Extend expiry by 14 days and document justification
|
||||
# 4. If extended 3+ times: Open an upstream issue on crowdsecurity/crowdsec
|
||||
|
||||
# CVE-2026-33186 (Caddy) — see full justification in the CrowdSec entry above
|
||||
# Package: google.golang.org/grpc v1.79.1 (embedded in /usr/bin/caddy)
|
||||
# Status: Fix available at v1.79.3 — waiting on a new Caddy release built with patched grpc
|
||||
- vulnerability: CVE-2026-33186
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
version: "v1.79.1"
|
||||
type: go-module
|
||||
reason: |
|
||||
CRITICAL — gRPC-Go authorization bypass in grpc v1.79.1 embedded in /usr/bin/caddy.
|
||||
Fix available at v1.79.3; waiting on Caddy upstream to release a build with patched grpc.
|
||||
Caddy's grpc server is not exposed externally in a standard Charon deployment.
|
||||
Risk accepted pending Caddy upstream fix. Reviewed 2026-03-19: no Caddy release with grpc >= v1.79.3 yet.
|
||||
expiry: "2026-04-02" # 14-day review: fix exists at v1.79.3; check Caddy releases.
|
||||
|
||||
# Action items when this suppression expires:
|
||||
# 1. Check Caddy releases: https://github.com/caddyserver/caddy/releases
|
||||
# (or the custom caddy-builder in the Dockerfile for caddy-security plugin)
|
||||
# 2. If a new Caddy build ships with grpc >= v1.79.3:
|
||||
# a. Update the Caddy version pin in the Dockerfile caddy-builder stage
|
||||
# b. Rebuild Docker image and run local security-scan-docker-image
|
||||
# c. Remove this suppression entry and the corresponding .trivyignore entry
|
||||
# 3. If no fix yet: Extend expiry by 14 days and document justification
|
||||
# 4. If extended 3+ times: Open an issue on caddyserver/caddy
|
||||
|
||||
# GHSA-479m-364c-43vc: goxmldsig XML signature validation bypass (loop variable capture)
|
||||
# Severity: HIGH (CVSS 7.5)
|
||||
# Package: github.com/russellhaering/goxmldsig v1.5.0 (embedded in /usr/bin/caddy)
|
||||
# Status: Fix available at v1.6.0 — waiting on a new Caddy release built with patched goxmldsig
|
||||
#
|
||||
# Vulnerability Details:
|
||||
# - Loop variable capture in validateSignature causes the signature reference to always
|
||||
# point to the last element in SignedInfo.References; an attacker can substitute signed
|
||||
# element content and bypass XML signature integrity validation (CWE-347, CWE-682).
|
||||
# - CVSSv3: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
|
||||
#
|
||||
# Root Cause (Third-Party Binary):
|
||||
# - Charon does not use goxmldsig directly. The package is compiled into /usr/bin/caddy
|
||||
# via the caddy-security plugin's SAML/SSO support.
|
||||
# - Fix path: once Caddy (or the caddy-security plugin) releases a build with
|
||||
# goxmldsig >= v1.6.0, rebuild the Docker image and remove this suppression.
|
||||
#
|
||||
# Risk Assessment: ACCEPTED (Low exploitability in default Charon context)
|
||||
# - The vulnerability only affects SAML/XML signature validation workflows.
|
||||
# - Charon does not enable or configure SAML-based SSO in its default setup.
|
||||
# - Exploiting this requires an active SAML integration, which is non-default.
|
||||
#
|
||||
# Mitigation (active while suppression is in effect):
|
||||
# - Monitor caddy-security plugin releases: https://github.com/greenpau/caddy-security/releases
|
||||
# - Monitor Caddy releases: https://github.com/caddyserver/caddy/releases
|
||||
# - Weekly CI security rebuild flags the moment a fixed image ships.
|
||||
#
|
||||
# Review:
|
||||
# - Reviewed 2026-03-19 (initial suppression): goxmldsig v1.6.0 fix exists; Caddy has not
|
||||
# yet shipped with the updated dep. Set 14-day review given fix availability.
|
||||
# - Next review: 2026-04-02. Remove suppression once Caddy ships with goxmldsig >= v1.6.0.
|
||||
#
|
||||
# Removal Criteria:
|
||||
# - Caddy (or caddy-security plugin) releases a build with goxmldsig >= v1.6.0
|
||||
# - Rebuild Docker image, run security-scan-docker-image, confirm finding is resolved
|
||||
# - Remove this entry and the corresponding .trivyignore entry simultaneously
|
||||
#
|
||||
# References:
|
||||
# - GHSA-479m-364c-43vc: https://github.com/advisories/GHSA-479m-364c-43vc
|
||||
# - goxmldsig v1.6.0 fix: https://github.com/russellhaering/goxmldsig/releases/tag/v1.6.0
|
||||
# - caddy-security plugin: https://github.com/greenpau/caddy-security/releases
|
||||
- vulnerability: GHSA-479m-364c-43vc
|
||||
package:
|
||||
name: github.com/russellhaering/goxmldsig
|
||||
version: "v1.5.0"
|
||||
type: go-module
|
||||
reason: |
|
||||
HIGH — XML signature validation bypass in goxmldsig v1.5.0 embedded in /usr/bin/caddy.
|
||||
Fix available at v1.6.0; waiting on Caddy upstream to release a build with patched goxmldsig.
|
||||
Charon does not configure SAML-based SSO by default; the vulnerable XML signature path
|
||||
is not reachable in a standard deployment. Risk accepted pending Caddy upstream fix.
|
||||
Reviewed 2026-03-19: no Caddy release with goxmldsig >= v1.6.0 yet.
|
||||
expiry: "2026-04-02" # 14-day review: fix exists at v1.6.0; check Caddy/caddy-security releases.
|
||||
|
||||
# Action items when this suppression expires:
|
||||
# 1. Check caddy-security releases: https://github.com/greenpau/caddy-security/releases
|
||||
# 2. If a new build ships with goxmldsig >= v1.6.0:
|
||||
# a. Update the Caddy version pin in the Dockerfile caddy-builder stage if needed
|
||||
# b. Rebuild Docker image and run local security-scan-docker-image
|
||||
# c. Remove this suppression entry and the corresponding .trivyignore entry
|
||||
# 3. If no fix yet: Extend expiry by 14 days and document justification
|
||||
|
||||
# GHSA-6g7g-w4f8-9c9x: buger/jsonparser Delete panic on malformed JSON (DoS)
|
||||
# Severity: HIGH (CVSS 7.5)
|
||||
# Package: github.com/buger/jsonparser v1.1.1 (embedded in /usr/local/bin/crowdsec and /usr/local/bin/cscli)
|
||||
|
||||
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Notifications:** Added Ntfy notification provider with support for self-hosted and cloud instances, optional Bearer token authentication, and JSON template customization
|
||||
|
||||
- **Certificate Deletion**: Clean up expired and unused certificates directly from the Certificates page
|
||||
- Expired Let's Encrypt certificates not attached to any proxy host can now be deleted
|
||||
- Custom and staging certificates remain deletable when not in use
|
||||
@@ -55,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Notifications:** Fixed Pushover token-clearing bug where tokens were silently stripped on provider create/update
|
||||
- **TCP Monitor Creation**: Fixed misleading form UX that caused silent HTTP 500 errors when creating TCP monitors
|
||||
- Corrected URL placeholder to show `host:port` format instead of the incorrect `tcp://host:port` prefix
|
||||
- Added dynamic per-type placeholder and helper text (HTTP monitors show a full URL example; TCP monitors show `host:port`)
|
||||
|
||||
@@ -43,7 +43,7 @@ ARG CADDY_CANDIDATE_VERSION=2.11.2
|
||||
ARG CADDY_USE_CANDIDATE=0
|
||||
ARG CADDY_PATCH_SCENARIO=B
|
||||
# renovate: datasource=go depName=github.com/greenpau/caddy-security
|
||||
ARG CADDY_SECURITY_VERSION=1.1.50
|
||||
ARG CADDY_SECURITY_VERSION=1.1.51
|
||||
# renovate: datasource=go depName=github.com/corazawaf/coraza-caddy
|
||||
ARG CORAZA_CADDY_VERSION=2.2.0
|
||||
## When an official caddy image tag isn't available on the host, use a
|
||||
|
||||
206
SECURITY.md
206
SECURITY.md
@@ -27,49 +27,7 @@ public disclosure.
|
||||
|
||||
## Known Vulnerabilities
|
||||
|
||||
### [CRITICAL] CVE-2025-68121 · Go Stdlib Critical in CrowdSec Bundled Binaries
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| **ID** | CVE-2025-68121 (see also CHARON-2025-001) |
|
||||
| **Severity** | Critical |
|
||||
| **Status** | Awaiting Upstream |
|
||||
|
||||
**What**
|
||||
A critical Go standard library vulnerability affects CrowdSec binaries bundled in the Charon
|
||||
container image. The binaries were compiled against Go 1.25.6, which contains this flaw.
|
||||
Charon's own application code, compiled with Go 1.26.1, is unaffected.
|
||||
|
||||
**Who**
|
||||
|
||||
- Discovered by: Automated scan (Grype)
|
||||
- Reported: 2026-03-20
|
||||
- Affects: CrowdSec Agent component within the container; not directly exposed through Charon's
|
||||
primary application interface
|
||||
|
||||
**Where**
|
||||
|
||||
- Component: CrowdSec Agent (bundled `cscli` and `crowdsec` binaries)
|
||||
- Versions affected: Charon container images with CrowdSec binaries compiled against Go < 1.25.7
|
||||
|
||||
**When**
|
||||
|
||||
- Discovered: 2026-03-20
|
||||
- Disclosed (if public): Not yet publicly disclosed
|
||||
- Target fix: When `golang:1.26.2-alpine` is published on Docker Hub
|
||||
|
||||
**How**
|
||||
The vulnerability resides entirely within CrowdSec's compiled binary artifacts. Exploitation
|
||||
is limited to the CrowdSec agent's internal execution paths, which are not externally exposed
|
||||
through Charon's API or network interface.
|
||||
|
||||
**Planned Remediation**
|
||||
`golang:1.26.2-alpine` is not yet available on Docker Hub. The `GO_VERSION` ARG has been
|
||||
reverted to `1.26.1` (the latest published image) until `1.26.2` is released. Once
|
||||
`golang:1.26.2-alpine` is available, bumping `GO_VERSION` to `1.26.2` and rebuilding the image
|
||||
will also resolve CVE-2026-25679 (High) and CVE-2025-61732 (High) tracked under CHARON-2025-001.
|
||||
|
||||
---
|
||||
Last reviewed: 2026-03-24
|
||||
|
||||
### [HIGH] CVE-2026-2673 · OpenSSL TLS 1.3 Key Exchange Group Downgrade
|
||||
|
||||
@@ -115,13 +73,135 @@ available, update the pinned `ALPINE_IMAGE` digest in the Dockerfile, or add an
|
||||
|
||||
---
|
||||
|
||||
### [HIGH] CHARON-2025-001 · CrowdSec Bundled Binaries — Go Stdlib CVEs
|
||||
### [MEDIUM] CVE-2025-60876 · BusyBox wget HTTP Request Smuggling
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| **ID** | CVE-2025-60876 |
|
||||
| **Severity** | Medium · 6.5 |
|
||||
| **Status** | Awaiting Upstream |
|
||||
|
||||
**What**
|
||||
BusyBox wget through 1.37 accepts raw CR/LF and other C0 control bytes in the HTTP
|
||||
request-target, allowing request line splitting and header injection (CWE-284).
|
||||
|
||||
**Who**
|
||||
|
||||
- Discovered by: Automated scan (Grype)
|
||||
- Reported: 2026-03-24
|
||||
- Affects: Container runtime environment; Charon does not invoke busybox wget in application logic
|
||||
|
||||
**Where**
|
||||
|
||||
- Component: Alpine 3.23.3 base image (`busybox` 1.37.0-r30)
|
||||
- Versions affected: All Charon images using Alpine 3.23.3 with busybox < patched version
|
||||
|
||||
**When**
|
||||
|
||||
- Discovered: 2026-03-24
|
||||
- Disclosed (if public): Not yet publicly disclosed with fix
|
||||
- Target fix: When Alpine Security publishes a patched busybox APK
|
||||
|
||||
**How**
|
||||
The vulnerable wget applet would need to be manually invoked inside the container with
|
||||
attacker-controlled URLs. Charon's application logic does not use busybox wget. EPSS score is
|
||||
0.00064 (0.20 percentile), indicating extremely low exploitation probability.
|
||||
|
||||
**Planned Remediation**
|
||||
Monitor Alpine 3.23 for a patched busybox APK. No immediate action required. Practical risk to
|
||||
Charon users is negligible since the vulnerable code path is not exercised.
|
||||
|
||||
---
|
||||
|
||||
### [LOW] CVE-2026-26958 · edwards25519 MultiScalarMult Invalid Results
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| **ID** | CVE-2026-26958 (GHSA-fw7p-63qq-7hpr) |
|
||||
| **Severity** | Low · 1.7 |
|
||||
| **Status** | Awaiting Upstream |
|
||||
|
||||
**What**
|
||||
`filippo.io/edwards25519` v1.1.0 `MultiScalarMult` produces invalid results or undefined
|
||||
behavior if the receiver is not the identity point. Fix available at v1.1.1 but requires
|
||||
CrowdSec to rebuild.
|
||||
|
||||
**Who**
|
||||
|
||||
- Discovered by: Automated scan (Grype)
|
||||
- Reported: 2026-03-24
|
||||
- Affects: CrowdSec Agent component within the container; not directly exposed through Charon's
|
||||
primary application interface
|
||||
|
||||
**Where**
|
||||
|
||||
- Component: CrowdSec Agent (bundled `cscli` and `crowdsec` binaries)
|
||||
- Versions affected: CrowdSec builds using `filippo.io/edwards25519` < v1.1.1
|
||||
|
||||
**When**
|
||||
|
||||
- Discovered: 2026-03-24
|
||||
- Disclosed (if public): Public
|
||||
- Target fix: When CrowdSec releases a build with updated dependency
|
||||
|
||||
**How**
|
||||
This is a rarely used advanced API within the edwards25519 library. CrowdSec does not directly
|
||||
expose MultiScalarMult to external input. EPSS score is 0.00018 (0.04 percentile).
|
||||
|
||||
**Planned Remediation**
|
||||
Awaiting CrowdSec upstream release with updated dependency. No action available for Charon
|
||||
maintainers.
|
||||
|
||||
---
|
||||
|
||||
## Patched Vulnerabilities
|
||||
|
||||
### ✅ [CRITICAL] CVE-2025-68121 · Go Stdlib Critical in CrowdSec Bundled Binaries
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| **ID** | CVE-2025-68121 (see also CHARON-2025-001) |
|
||||
| **Severity** | Critical |
|
||||
| **Patched** | 2026-03-24 |
|
||||
|
||||
**What**
|
||||
A critical Go standard library vulnerability affects CrowdSec binaries bundled in the Charon
|
||||
container image. The binaries were compiled against Go 1.25.6, which contains this flaw.
|
||||
Charon's own application code, compiled with Go 1.26.1, is unaffected.
|
||||
|
||||
**Who**
|
||||
|
||||
- Discovered by: Automated scan (Grype)
|
||||
- Reported: 2026-03-20
|
||||
|
||||
**Where**
|
||||
|
||||
- Component: CrowdSec Agent (bundled `cscli` and `crowdsec` binaries)
|
||||
- Versions affected: Charon container images with CrowdSec binaries compiled against Go < 1.25.7
|
||||
|
||||
**When**
|
||||
|
||||
- Discovered: 2026-03-20
|
||||
- Patched: 2026-03-24
|
||||
- Time to patch: 4 days
|
||||
|
||||
**How**
|
||||
The vulnerability resides entirely within CrowdSec's compiled binary artifacts. Exploitation
|
||||
is limited to the CrowdSec agent's internal execution paths, which are not externally exposed
|
||||
through Charon's API or network interface.
|
||||
|
||||
**Resolution**
|
||||
CrowdSec binaries now compiled with Go 1.26.1 (was 1.25.6).
|
||||
|
||||
---
|
||||
|
||||
### ✅ [HIGH] CHARON-2025-001 · CrowdSec Bundled Binaries — Go Stdlib CVEs
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| **ID** | CHARON-2025-001 (aliases: CVE-2025-58183, CVE-2025-58186, CVE-2025-58187, CVE-2025-61729, CVE-2026-25679, CVE-2025-61732, CVE-2026-27142, CVE-2026-27139) |
|
||||
| **Severity** | High · (preliminary, CVSS scores pending upstream confirmation) |
|
||||
| **Status** | Awaiting Upstream |
|
||||
| **Patched** | 2026-03-24 |
|
||||
|
||||
**What**
|
||||
Multiple CVEs in Go standard library packages continue to accumulate in CrowdSec binaries bundled
|
||||
@@ -135,8 +215,6 @@ Charon's own application code is unaffected.
|
||||
|
||||
- Discovered by: Automated scan (Trivy, Grype)
|
||||
- Reported: 2025-12-01 (original cluster); expanded 2026-03-20
|
||||
- Affects: CrowdSec Agent component within the container; not directly exposed through Charon's
|
||||
primary application interface
|
||||
|
||||
**Where**
|
||||
|
||||
@@ -146,29 +224,26 @@ Charon's own application code is unaffected.
|
||||
**When**
|
||||
|
||||
- Discovered: 2025-12-01
|
||||
- Disclosed (if public): Not yet publicly disclosed
|
||||
- Target fix: When `golang:1.26.2-alpine` is published on Docker Hub
|
||||
- Patched: 2026-03-24
|
||||
- Time to patch: 114 days
|
||||
|
||||
**How**
|
||||
The CVEs reside entirely within CrowdSec's compiled binaries and cover HTTP/2, TLS, and archive
|
||||
processing paths that are not invoked by Charon's core application logic. The relevant network
|
||||
interfaces are not externally exposed via Charon's API surface.
|
||||
|
||||
**Planned Remediation**
|
||||
`golang:1.26.2-alpine` is not yet available on Docker Hub. The `GO_VERSION` ARG has been
|
||||
reverted to `1.26.1` (the latest published image) until `1.26.2` is released. Once available,
|
||||
bumping `GO_VERSION` to `1.26.2` and rebuilding the image will resolve the entire alias cluster.
|
||||
CVE-2025-68121 (Critical severity, same root cause) is tracked separately above.
|
||||
**Resolution**
|
||||
CrowdSec binaries now compiled with Go 1.26.1.
|
||||
|
||||
---
|
||||
|
||||
### [MEDIUM] CVE-2026-27171 · zlib CPU Exhaustion via Infinite Loop in CRC Combine Functions
|
||||
### ✅ [MEDIUM] CVE-2026-27171 · zlib CPU Exhaustion via Infinite Loop in CRC Combine Functions
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| **ID** | CVE-2026-27171 |
|
||||
| **Severity** | Medium · 5.5 (NVD) / 2.9 (MITRE) |
|
||||
| **Status** | Awaiting Upstream |
|
||||
| **Patched** | 2026-03-24 |
|
||||
|
||||
**What**
|
||||
zlib before 1.3.2 allows unbounded CPU consumption (denial of service) via the `crc32_combine64`
|
||||
@@ -180,8 +255,6 @@ loop with no termination condition when given a specially crafted input, causing
|
||||
|
||||
- Discovered by: 7aSecurity audit (commissioned by OSTIF)
|
||||
- Reported: 2026-02-17
|
||||
- Affects: Any component in the container that calls `crc32_combine`-family functions with
|
||||
attacker-controlled input; not directly exposed through Charon's application interface
|
||||
|
||||
**Where**
|
||||
|
||||
@@ -190,25 +263,20 @@ loop with no termination condition when given a specially crafted input, causing
|
||||
|
||||
**When**
|
||||
|
||||
- Discovered: 2026-02-17 (NVD published 2026-02-17)
|
||||
- Disclosed (if public): 2026-02-17
|
||||
- Target fix: When Alpine 3.23 publishes a patched `zlib` APK (requires zlib 1.3.2)
|
||||
- Discovered: 2026-02-17
|
||||
- Patched: 2026-03-24
|
||||
- Time to patch: 35 days
|
||||
|
||||
**How**
|
||||
Exploitation requires local access (CVSS vector `AV:L`) and the ability to pass a crafted value
|
||||
to the `crc32_combine`-family functions. This code path is not invoked by Charon's reverse proxy
|
||||
or backend API. The vulnerability is non-blocking under the project's CI severity policy.
|
||||
|
||||
**Planned Remediation**
|
||||
Monitor <https://security.alpinelinux.org/vuln/CVE-2026-27171> for a patched Alpine APK. Once
|
||||
available, update the pinned `ALPINE_IMAGE` digest in the Dockerfile, or add an explicit
|
||||
`RUN apk upgrade --no-cache zlib` to the runtime stage. Remove the `.trivyignore` entry at
|
||||
that time.
|
||||
**Resolution**
|
||||
Alpine now ships zlib 1.3.2-r0 (fix threshold was 1.3.2).
|
||||
|
||||
---
|
||||
|
||||
## Patched Vulnerabilities
|
||||
|
||||
### ✅ [HIGH] CHARON-2026-001 · Debian Base Image CVE Cluster
|
||||
|
||||
| Field | Value |
|
||||
@@ -565,4 +633,4 @@ We recognize security researchers who help improve Charon:
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-03-20
|
||||
**Last Updated**: 2026-03-24
|
||||
|
||||
@@ -70,7 +70,7 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
|
||||
@@ -130,8 +130,8 @@ github.com/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7L
|
||||
github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
||||
@@ -126,11 +126,11 @@ func isLocalRequest(c *gin.Context) bool {
|
||||
}
|
||||
|
||||
// setSecureCookie sets an auth cookie with security best practices
|
||||
// - HttpOnly: prevents JavaScript access (XSS protection)
|
||||
// - Secure: always true (all major browsers honour Secure on localhost HTTP;
|
||||
// HTTP-on-private-IP without TLS is an unsupported deployment)
|
||||
// - SameSite: Lax for any local/private-network request (regardless of scheme),
|
||||
// Strict otherwise (public HTTPS only)
|
||||
// - HttpOnly: prevents JavaScript access (XSS protection)
|
||||
// - Secure: always true (all major browsers honour Secure on localhost HTTP;
|
||||
// HTTP-on-private-IP without TLS is an unsupported deployment)
|
||||
// - SameSite: Lax for any local/private-network request (regardless of scheme),
|
||||
// Strict otherwise (public HTTPS only)
|
||||
func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
scheme := requestScheme(c)
|
||||
sameSite := http.SameSiteStrictMode
|
||||
|
||||
@@ -182,7 +182,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" {
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
@@ -242,12 +242,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(existing.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" {
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover") && strings.TrimSpace(req.Token) == "" {
|
||||
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy") && strings.TrimSpace(req.Token) == "" {
|
||||
// Keep existing token if update payload omits token
|
||||
req.Token = existing.Token
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ const (
|
||||
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
|
||||
FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled"
|
||||
FlagPushoverServiceEnabled = "feature.notifications.service.pushover.enabled"
|
||||
FlagNtfyServiceEnabled = "feature.notifications.service.ntfy.enabled"
|
||||
FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled"
|
||||
)
|
||||
|
||||
@@ -458,10 +458,11 @@ func readCappedResponseBody(body io.Reader) ([]byte, error) {
|
||||
|
||||
func sanitizeOutboundHeaders(headers map[string]string) map[string]string {
|
||||
allowed := map[string]struct{}{
|
||||
"content-type": {},
|
||||
"user-agent": {},
|
||||
"x-request-id": {},
|
||||
"x-gotify-key": {},
|
||||
"content-type": {},
|
||||
"user-agent": {},
|
||||
"x-request-id": {},
|
||||
"x-gotify-key": {},
|
||||
"authorization": {},
|
||||
}
|
||||
|
||||
sanitized := make(map[string]string)
|
||||
|
||||
@@ -255,11 +255,11 @@ func TestSanitizeOutboundHeadersAllowlist(t *testing.T) {
|
||||
"Cookie": "sid=1",
|
||||
})
|
||||
|
||||
if len(headers) != 4 {
|
||||
t.Fatalf("expected 4 allowed headers, got %d", len(headers))
|
||||
if len(headers) != 5 {
|
||||
t.Fatalf("expected 5 allowed headers, got %d", len(headers))
|
||||
}
|
||||
if _, ok := headers["Authorization"]; ok {
|
||||
t.Fatalf("authorization header must be stripped")
|
||||
if _, ok := headers["Authorization"]; !ok {
|
||||
t.Fatalf("authorization header must be allowed for ntfy Bearer auth")
|
||||
}
|
||||
if _, ok := headers["Cookie"]; ok {
|
||||
t.Fatalf("cookie header must be stripped")
|
||||
|
||||
@@ -29,6 +29,8 @@ func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) boo
|
||||
return flags[FlagSlackServiceEnabled]
|
||||
case "pushover":
|
||||
return flags[FlagPushoverServiceEnabled]
|
||||
case "ntfy":
|
||||
return flags[FlagNtfyServiceEnabled]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
flags := map[string]bool{
|
||||
FlagNotifyEngineEnabled: true,
|
||||
FlagNotifyEngineEnabled: true,
|
||||
FlagPushoverServiceEnabled: true,
|
||||
}
|
||||
|
||||
@@ -122,3 +122,21 @@ func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) {
|
||||
t.Fatalf("expected notify routing disabled for pushover when FlagPushoverServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_ShouldUseNotify_NtfyServiceFlag(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
flags := map[string]bool{
|
||||
FlagNotifyEngineEnabled: true,
|
||||
FlagNtfyServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("ntfy", flags) {
|
||||
t.Fatalf("expected notify routing enabled for ntfy when FlagNtfyServiceEnabled is true")
|
||||
}
|
||||
|
||||
flags[FlagNtfyServiceEnabled] = false
|
||||
if router.ShouldUseNotify("ntfy", flags) {
|
||||
t.Fatalf("expected notify routing disabled for ntfy when FlagNtfyServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func validateDiscordProviderURL(providerType, rawURL string) error {
|
||||
// supportsJSONTemplates returns true if the provider type can use JSON templates
|
||||
func supportsJSONTemplates(providerType string) bool {
|
||||
switch strings.ToLower(providerType) {
|
||||
case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover":
|
||||
case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover", "ntfy":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -138,7 +138,7 @@ func supportsJSONTemplates(providerType string) bool {
|
||||
|
||||
func isSupportedNotificationProviderType(providerType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(providerType)) {
|
||||
case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover":
|
||||
case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -161,6 +161,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool {
|
||||
return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true)
|
||||
case "pushover":
|
||||
return s.getFeatureFlagValue(notifications.FlagPushoverServiceEnabled, true)
|
||||
case "ntfy":
|
||||
return s.getFeatureFlagValue(notifications.FlagNtfyServiceEnabled, true)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -520,9 +522,13 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti
|
||||
return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported")
|
||||
}
|
||||
}
|
||||
case "ntfy":
|
||||
if _, hasMessage := jsonPayload["message"]; !hasMessage {
|
||||
return fmt.Errorf("ntfy payload must include a 'message' field")
|
||||
}
|
||||
}
|
||||
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" {
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" {
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Charon-Notify/1.0",
|
||||
@@ -579,6 +585,12 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti
|
||||
dispatchURL = decryptedWebhookURL
|
||||
}
|
||||
|
||||
if providerType == "ntfy" {
|
||||
if strings.TrimSpace(p.Token) != "" {
|
||||
headers["Authorization"] = "Bearer " + strings.TrimSpace(p.Token)
|
||||
}
|
||||
}
|
||||
|
||||
if providerType == "pushover" {
|
||||
decryptedToken := p.Token
|
||||
if strings.TrimSpace(decryptedToken) == "" {
|
||||
@@ -847,7 +859,7 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid
|
||||
}
|
||||
}
|
||||
|
||||
if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" {
|
||||
if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" && provider.Type != "ntfy" && provider.Type != "pushover" {
|
||||
provider.Token = ""
|
||||
}
|
||||
|
||||
@@ -883,7 +895,7 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid
|
||||
return err
|
||||
}
|
||||
|
||||
if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" {
|
||||
if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" || provider.Type == "ntfy" || provider.Type == "pushover" {
|
||||
if strings.TrimSpace(provider.Token) == "" {
|
||||
provider.Token = existing.Token
|
||||
}
|
||||
|
||||
@@ -661,3 +661,96 @@ func TestSendJSONPayload_Telegram_401ErrorMessage(t *testing.T) {
|
||||
require.Error(t, sendErr)
|
||||
assert.Contains(t, sendErr.Error(), "provider returned status 401")
|
||||
}
|
||||
|
||||
func TestSendJSONPayload_Ntfy_Valid(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
assert.Empty(t, r.Header.Get("Authorization"), "no auth header when token is empty")
|
||||
|
||||
var payload map[string]any
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, payload["message"], "ntfy payload should have message field")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "ntfy",
|
||||
URL: server.URL,
|
||||
Template: "custom",
|
||||
Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Message": "Test notification",
|
||||
"Title": "Test",
|
||||
}
|
||||
|
||||
err = svc.sendJSONPayload(context.Background(), provider, data)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSendJSONPayload_Ntfy_WithToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer tk_test123", r.Header.Get("Authorization"))
|
||||
|
||||
var payload map[string]any
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, payload["message"])
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "ntfy",
|
||||
URL: server.URL,
|
||||
Token: "tk_test123",
|
||||
Template: "custom",
|
||||
Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Message": "Test notification",
|
||||
"Title": "Test",
|
||||
}
|
||||
|
||||
err = svc.sendJSONPayload(context.Background(), provider, data)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSendJSONPayload_Ntfy_MissingMessage(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "ntfy",
|
||||
URL: "http://localhost:9999",
|
||||
Template: "custom",
|
||||
Config: `{"title": "Test"}`,
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Message": "Test",
|
||||
}
|
||||
|
||||
err = svc.sendJSONPayload(context.Background(), provider, data)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ntfy payload must include a 'message' field")
|
||||
}
|
||||
|
||||
@@ -3878,3 +3878,31 @@ func TestPushoverDispatch_DefaultBaseURL(t *testing.T) {
|
||||
err := svc.sendJSONPayload(ctx, provider, data)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIsSupportedNotificationProviderType_Ntfy(t *testing.T) {
|
||||
assert.True(t, isSupportedNotificationProviderType("ntfy"))
|
||||
assert.True(t, isSupportedNotificationProviderType("Ntfy"))
|
||||
assert.True(t, isSupportedNotificationProviderType(" ntfy "))
|
||||
}
|
||||
|
||||
func TestIsDispatchEnabled_NtfyDefaultTrue(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
_ = db.AutoMigrate(&models.Setting{})
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
assert.True(t, svc.isDispatchEnabled("ntfy"))
|
||||
}
|
||||
|
||||
func TestIsDispatchEnabled_NtfyDisabledByFlag(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
_ = db.AutoMigrate(&models.Setting{})
|
||||
db.Create(&models.Setting{Key: "feature.notifications.service.ntfy.enabled", Value: "false"})
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
assert.False(t, svc.isDispatchEnabled("ntfy"))
|
||||
}
|
||||
|
||||
func TestSupportsJSONTemplates_Ntfy(t *testing.T) {
|
||||
assert.True(t, supportsJSONTemplates("ntfy"))
|
||||
assert.True(t, supportsJSONTemplates("Ntfy"))
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ Watch requests flow through your proxy in real-time. Filter by domain, status co
|
||||
|
||||
### 🔔 Notifications
|
||||
|
||||
Get alerted when it matters. Charon notifications now run through the Notify HTTP wrapper with support for Discord, Gotify, and Custom Webhook providers. Payload-focused test coverage is included to help catch formatting and delivery regressions before release.
|
||||
Get alerted when it matters. Charon sends notifications through Discord, Gotify, Ntfy, Pushover, Slack, Email, and Custom Webhook providers. Choose a built-in JSON template or write your own to control exactly what your alerts look like.
|
||||
|
||||
→ [Learn More](features/notifications.md)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Notifications can be triggered by various events:
|
||||
| **Slack** | ✅ Yes | ✅ Webhooks | ✅ Native Formatting |
|
||||
| **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras |
|
||||
| **Pushover** | ✅ Yes | ✅ HTTP API | ✅ Priority + Sound |
|
||||
| **Ntfy** | ✅ Yes | ✅ HTTP API | ✅ Priority + Tags |
|
||||
| **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled |
|
||||
| **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates |
|
||||
|
||||
@@ -260,6 +261,51 @@ Pushover delivers push notifications directly to your iOS, Android, or desktop d
|
||||
|
||||
> **Note:** Emergency priority (`2`) is not supported and will be rejected with a clear error.
|
||||
|
||||
### Ntfy
|
||||
|
||||
Ntfy delivers push notifications to your phone or desktop using a simple HTTP-based publish/subscribe model. Works with the free hosted service at [ntfy.sh](https://ntfy.sh) or your own self-hosted instance.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Pick a topic name (or use an existing one) on [ntfy.sh](https://ntfy.sh) or your self-hosted server
|
||||
2. In Charon, go to **Settings** → **Notifications** and click **"Add Provider"**
|
||||
3. Select **Ntfy** as the service type
|
||||
4. Enter your Topic URL (e.g., `https://ntfy.sh/charon-alerts` or `https://ntfy.example.com/charon-alerts`)
|
||||
5. (Optional) Add an access token if your topic requires authentication
|
||||
6. Configure notification triggers and save
|
||||
|
||||
> **Security:** Your access token is stored securely and is never exposed in API responses.
|
||||
|
||||
#### Basic Message
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "charon-alerts",
|
||||
"title": "{{.Title}}",
|
||||
"message": "{{.Message}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### Message with Priority and Tags
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "charon-alerts",
|
||||
"title": "{{.Title}}",
|
||||
"message": "{{.Message}}",
|
||||
"priority": 4,
|
||||
"tags": ["rotating_light"]
|
||||
}
|
||||
```
|
||||
|
||||
**Ntfy priority levels:**
|
||||
|
||||
- `1` - Min
|
||||
- `2` - Low
|
||||
- `3` - Default
|
||||
- `4` - High
|
||||
- `5` - Max (urgent)
|
||||
|
||||
## Planned Provider Expansion
|
||||
|
||||
Additional providers (for example Telegram) are planned for later staged
|
||||
|
||||
98
docs/issues/ntfy-notification-provider-manual-testing.md
Normal file
98
docs/issues/ntfy-notification-provider-manual-testing.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: "Manual Testing: Ntfy Notification Provider"
|
||||
labels:
|
||||
- testing
|
||||
- feature
|
||||
- frontend
|
||||
- backend
|
||||
priority: medium
|
||||
milestone: "v0.2.0-beta.2"
|
||||
assignees: []
|
||||
---
|
||||
|
||||
# Manual Testing: Ntfy Notification Provider
|
||||
|
||||
## Description
|
||||
|
||||
Manual testing plan for the Ntfy notification provider feature. Covers UI/UX
|
||||
validation, dispatch behavior, token security, and edge cases that E2E tests
|
||||
cannot fully cover.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ntfy instance accessible (cloud: ntfy.sh, or self-hosted)
|
||||
- Test topic created (e.g., `https://ntfy.sh/charon-test-XXXX`)
|
||||
- Ntfy mobile/desktop app installed for push verification
|
||||
- Optional: password-protected topic with access token for auth testing
|
||||
|
||||
## Test Cases
|
||||
|
||||
### UI/UX Validation
|
||||
|
||||
- [ ] Select "Ntfy" from provider type dropdown — token field and "Topic URL" label appear
|
||||
- [ ] URL placeholder shows `https://ntfy.sh/my-topic`
|
||||
- [ ] Token label shows "Access Token (optional)"
|
||||
- [ ] Token field is a password field (dots, not cleartext)
|
||||
- [ ] JSON template section (minimal/detailed/custom) appears for Ntfy
|
||||
- [ ] Switching from Ntfy to Discord clears token field and hides it
|
||||
- [ ] Switching from Discord to Ntfy shows token field again
|
||||
- [ ] URL field is required — form rejects empty URL submission
|
||||
- [ ] Keyboard navigation: tab through all Ntfy form fields without focus traps
|
||||
|
||||
### CRUD Operations
|
||||
|
||||
- [ ] Create Ntfy provider with URL only (no token) — succeeds
|
||||
- [ ] Create Ntfy provider with URL + token — succeeds
|
||||
- [ ] Edit Ntfy provider: change URL — preserves token (shows "Leave blank to keep")
|
||||
- [ ] Edit Ntfy provider: clear and re-enter token — updates token
|
||||
- [ ] Delete Ntfy provider — removed from list
|
||||
- [ ] Create multiple Ntfy providers with different topics — all coexist
|
||||
|
||||
### Dispatch Verification (Requires Real Ntfy Instance)
|
||||
|
||||
- [ ] Send test notification to ntfy.sh cloud topic — push received on device
|
||||
- [ ] Send test notification to self-hosted ntfy instance — push received
|
||||
- [ ] Send test notification with minimal template — message body is correct
|
||||
- [ ] Send test notification with detailed template — title and body formatted correctly
|
||||
- [ ] Send test notification with custom JSON template — all fields arrive as specified
|
||||
- [ ] Token-protected topic with valid token — notification delivered
|
||||
- [ ] Token-protected topic with no token — notification rejected by ntfy (expected 401)
|
||||
- [ ] Token-protected topic with invalid token — notification rejected by ntfy (expected 401)
|
||||
|
||||
### Token Security
|
||||
|
||||
- [ ] After creating provider with token: GET provider response has `has_token: true` but no raw token
|
||||
- [ ] Browser DevTools Network tab: confirm token never appears in any API response body
|
||||
- [ ] Edit provider: token field is empty (not pre-filled with existing token)
|
||||
- [ ] Application logs: confirm no token values in backend logs during dispatch
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- [ ] Invalid URL (not http/https) — form validation rejects
|
||||
- [ ] Self-hosted ntfy URL with non-standard port (e.g., `http://192.168.1.50:8080/alerts`) — accepted and dispatches
|
||||
- [ ] Very long topic name in URL — accepted
|
||||
- [ ] Unicode characters in message template — dispatches correctly
|
||||
- [ ] Feature flag disabled (`feature.notifications.service.ntfy.enabled = false`) — ntfy dispatch silently skipped
|
||||
- [ ] Network timeout to unreachable ntfy server — error handled gracefully, no crash
|
||||
|
||||
### Accessibility
|
||||
|
||||
- [ ] Screen reader: form field labels announced correctly for Ntfy fields
|
||||
- [ ] Screen reader: token help text associated via aria-describedby
|
||||
- [ ] High contrast mode: Ntfy form fields visible and readable
|
||||
- [ ] Voice access: "Click Topic URL" activates the correct field
|
||||
- [ ] Keyboard only: complete full CRUD workflow without mouse
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All UI/UX tests pass
|
||||
- [ ] All CRUD operations work correctly
|
||||
- [ ] At least one real dispatch to ntfy.sh confirmed
|
||||
- [ ] Token never exposed in API responses or logs
|
||||
- [ ] No accessibility regressions
|
||||
|
||||
## Related
|
||||
|
||||
- Spec: `docs/plans/current_spec.md`
|
||||
- QA Report: `docs/reports/qa_report_ntfy_notifications.md`
|
||||
- E2E Tests: `tests/settings/ntfy-notification-provider.spec.ts`
|
||||
@@ -1,204 +1,592 @@
|
||||
# Fix: Frontend Unit Test i18n Failures in BulkDeleteCertificateDialog
|
||||
|
||||
> **Status:** Ready for implementation
|
||||
> **Severity:** CI-blocking (2 test failures)
|
||||
> **Scope:** Single test file change
|
||||
|
||||
---
|
||||
# Ntfy Notification Provider — Implementation Specification
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
Two frontend unit tests fail in CI because `BulkDeleteCertificateDialog.test.tsx` contains a local `vi.mock('react-i18next')` that overrides the global mock in the test setup. The local mock returns raw translation keys and JSON-serialized options instead of resolved English strings, causing assertion mismatches.
|
||||
### Overview
|
||||
|
||||
Add **Ntfy** (<https://ntfy.sh>) as a notification provider in Charon, following
|
||||
the same wrapper pattern used by Gotify, Telegram, Slack, and Pushover. Ntfy is
|
||||
an HTTP-based pub/sub notification service that supports self-hosted and
|
||||
cloud-hosted instances. Users publish messages by POSTing JSON to a topic URL,
|
||||
optionally with an auth token.
|
||||
|
||||
### Objectives
|
||||
|
||||
- Fix the 2 failing tests in CI
|
||||
- Align `BulkDeleteCertificateDialog.test.tsx` with the project's established i18n test pattern
|
||||
- No behavioral or component changes required
|
||||
1. Users can create/edit/delete an Ntfy notification provider via the Management UI.
|
||||
2. Ntfy dispatches support all three template modes (minimal, detailed, custom).
|
||||
3. Ntfy respects the global notification engine kill-switch and its own per-provider feature flag.
|
||||
4. Security: auth tokens are stored securely (never exposed in API responses or logs).
|
||||
5. Full E2E and unit test coverage matching the existing provider test suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Failing Tests (from CI log)
|
||||
### Existing Architecture
|
||||
|
||||
| # | Test Name | Expected | Actual (DOM) |
|
||||
|---|-----------|----------|--------------|
|
||||
| 1 | `lists each certificate name in the scrollable list` | `"Custom"`, `"Staging"`, `"Expired LE"` | `certificates.providerCustom`, `certificates.providerStaging`, `certificates.providerExpiredLE` |
|
||||
| 2 | `renders "Expiring LE" label for a letsencrypt cert with status expiring` | `"Expiring LE"` | `certificates.providerExpiringLE` |
|
||||
Charon's notification engine does **not** use a Go interface pattern. Instead, it
|
||||
routes on string type values (`"discord"`, `"gotify"`, `"webhook"`, etc.) across
|
||||
~15 switch/case + hardcoded lists in both backend and frontend.
|
||||
|
||||
Additional rendering artifacts visible in the DOM dump:
|
||||
**Key code paths per provider type:**
|
||||
|
||||
- Dialog title: `{"count":3}` instead of `"Delete 3 Certificate(s)"`
|
||||
- Button text: `{"count":3}` instead of `"Delete 3 Certificate(s)"`
|
||||
- Cancel button: `common.cancel` instead of `"Cancel"`
|
||||
- Warning text: `certificates.bulkDeleteConfirm` instead of translated string
|
||||
- Aria label: `certificates.bulkDeleteListAriaLabel` instead of translated string
|
||||
| Layer | Location | Mechanism |
|
||||
|-------|----------|-----------|
|
||||
| Model | `backend/internal/models/notification_provider.go` | Generic — no per-type changes needed |
|
||||
| Service — type allowlist | `notification_service.go:139` `isSupportedNotificationProviderType()` | `switch` on type string |
|
||||
| Service — flag routing | `notification_service.go:148` `isDispatchEnabled()` | `switch` → feature flag lookup |
|
||||
| Service — dispatch | `notification_service.go:381` `sendJSONPayload()` | Type-specific validation + URL / header construction |
|
||||
| Feature flags | `notifications/feature_flags.go` | Const strings for settings DB keys |
|
||||
| Router | `notifications/router.go:10` `ShouldUseNotify()` | `switch` on type → flag map lookup |
|
||||
| Handler — create validation | `notification_provider_handler.go:185` | Hardcoded `!=` chain |
|
||||
| Handler — update validation | `notification_provider_handler.go:245` | Hardcoded `!=` chain |
|
||||
| Handler — URL validation | `notification_provider_handler.go:372` | Slack special-case (optional URL) |
|
||||
| Frontend — type array | `api/notifications.ts:3` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` const |
|
||||
| Frontend — sanitize | `api/notifications.ts` `sanitizeProviderForWriteAction()` | Token mapping per type |
|
||||
| Frontend — form | `pages/Notifications.tsx` | `<option>`, URL label, token field, placeholder, `supportsJSONTemplates()`, `normalizeProviderPayloadForSubmit()`, `useEffect` token cleanup |
|
||||
| Frontend — unit test mock | `pages/__tests__/Notifications.test.tsx` | Mock of `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` |
|
||||
| i18n | `locales/{en,de,fr,zh,es}/translation.json` | `notificationProviders.*` keys |
|
||||
|
||||
### 2.2 Relevant File Paths
|
||||
### Ntfy HTTP API Reference
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` | **Failing test file** — contains the problematic local mock |
|
||||
| `frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx` | Component under test |
|
||||
| `frontend/src/test/setup.ts` | Global test setup with proper i18n mock (lines 20–60) |
|
||||
| `frontend/vitest.config.ts` | Vitest config — confirms `setupFiles: './src/test/setup.ts'` (line 24) |
|
||||
| `frontend/src/locales/en/translation.json` | English translations source |
|
||||
Ntfy accepts a JSON POST to a topic URL:
|
||||
|
||||
### 2.3 i18n Mock Architecture
|
||||
```
|
||||
POST https://ntfy.sh/my-topic
|
||||
Authorization: Bearer tk_abc123 # optional
|
||||
Content-Type: application/json
|
||||
|
||||
**Global mock** (`frontend/src/test/setup.ts`, lines 20–60):
|
||||
|
||||
- Dynamically imports `../locales/en/translation.json`
|
||||
- Implements `getTranslation(key)` that resolves dot-notation keys (e.g., `certificates.providerCustom` → `"Custom"`)
|
||||
- Handles `{{variable}}` interpolation via regex replacement
|
||||
- Applied automatically to all test files via `setupFiles` in vitest config
|
||||
|
||||
**Local mock** (`BulkDeleteCertificateDialog.test.tsx`, lines 9–14):
|
||||
|
||||
```typescript
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => (opts ? JSON.stringify(opts) : key),
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
{
|
||||
"topic": "my-topic", // optional if encoded in URL
|
||||
"message": "Hello!", // required
|
||||
"title": "Alert Title", // optional
|
||||
"priority": 3, // optional (1-5, default 3)
|
||||
"tags": ["warning"] // optional
|
||||
}
|
||||
```
|
||||
|
||||
This local mock **overrides** the global mock because Vitest's `vi.mock()` at the file level takes precedence over the setup file's `vi.mock()`. It returns:
|
||||
This maps directly to the Gotify dispatch pattern: POST JSON to `p.URL` with an
|
||||
optional `Authorization: Bearer <token>` header.
|
||||
|
||||
- Raw key when no options: `t('certificates.providerCustom')` → `"certificates.providerCustom"`
|
||||
- JSON string when options present: `t('key', { count: 3 })` → `'{"count":3}'`
|
||||
---
|
||||
|
||||
### 2.4 Translation Keys Required
|
||||
## 3. Technical Specifications
|
||||
|
||||
From `frontend/src/locales/en/translation.json`:
|
||||
### 3.1 Provider Interface / Contract (Type Registration)
|
||||
|
||||
Ntfy uses type string `"ntfy"`. Every switch/case and hardcoded type list must
|
||||
include this value. The following table is the exhaustive changeset:
|
||||
|
||||
| # | File | Function / Location | Change |
|
||||
|---|------|---------------------|--------|
|
||||
| 1 | `backend/internal/services/notification_service.go` | `isSupportedNotificationProviderType()` ~L139 | Add `case "ntfy": return true` |
|
||||
| 2 | `backend/internal/services/notification_service.go` | `isDispatchEnabled()` ~L148 | Add `case "ntfy":` with `FlagNtfyServiceEnabled`, default `true` |
|
||||
| 3 | `backend/internal/services/notification_service.go` | `sendJSONPayload()` — validation block ~L460 | Add ntfy JSON validation: require `"message"` field |
|
||||
| 4 | `backend/internal/services/notification_service.go` | `sendJSONPayload()` — dispatch routing ~L530 | Add ntfy dispatch block (URL from `p.URL`, optional Bearer auth from `p.Token`) |
|
||||
| 5 | `backend/internal/services/notification_service.go` | `supportsJSONTemplates()` ~L131 | Add `case "ntfy": return true` — gates `SendExternal()` JSON dispatch path |
|
||||
| 6 | `backend/internal/services/notification_service.go` | `sendJSONPayload()` — outer gating condition ~L525 | Add `\|\| providerType == "ntfy"` to the if-chain that enters the dispatch block |
|
||||
| 7 | `backend/internal/services/notification_service.go` | `CreateProvider()` — token-clearing condition ~L851 | Add `&& provider.Type != "ntfy"` (and `&& provider.Type != "pushover"` — existing bug fix) to prevent token being silently cleared on creation |
|
||||
| 8 | `backend/internal/services/notification_service.go` | `UpdateProvider()` — token preservation ~L886 | Add `\|\| provider.Type == "ntfy"` (and `\|\| provider.Type == "pushover"` — existing bug fix) to preserve token on update when not re-entered |
|
||||
| 9 | `backend/internal/notifications/feature_flags.go` | Constants | Add `FlagNtfyServiceEnabled = "feature.notifications.service.ntfy.enabled"` |
|
||||
| 10 | `backend/internal/notifications/router.go` | `ShouldUseNotify()` | Add `case "ntfy": return flags[FlagNtfyServiceEnabled]` |
|
||||
| 11 | `backend/internal/api/handlers/notification_provider_handler.go` | `Create()` ~L185 | Add `&& providerType != "ntfy"` to validation chain |
|
||||
| 12 | `backend/internal/api/handlers/notification_provider_handler.go` | `Update()` ~L245 | Add `&& providerType != "ntfy"` to validation chain |
|
||||
| 13 | `backend/internal/api/handlers/notification_provider_handler.go` | `Update()` — token preservation ~L250 | Add `\|\| providerType == "ntfy"` to the condition that preserves existing token when update payload omits it |
|
||||
| 14 | `frontend/src/api/notifications.ts` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` | Add `'ntfy'` to array |
|
||||
| 15 | `frontend/src/api/notifications.ts` | `sanitizeProviderForWriteAction()` | Add `'ntfy'` to token-bearing types |
|
||||
| 16 | `frontend/src/pages/Notifications.tsx` | `supportsJSONTemplates()` | Add `|| t === 'ntfy'` |
|
||||
| 17 | `frontend/src/pages/Notifications.tsx` | `normalizeProviderPayloadForSubmit()` | Add `'ntfy'` to token-bearing types |
|
||||
| 18 | `frontend/src/pages/Notifications.tsx` | `useEffect` token cleanup | Add `type !== 'ntfy'` to the cleanup condition |
|
||||
| 19 | `frontend/src/pages/Notifications.tsx` | `<select>` dropdown | Add `<option value="ntfy">Ntfy</option>` |
|
||||
| 20 | `frontend/src/pages/Notifications.tsx` | URL label ternary | Ntfy uses default URL/Webhook label — no special label needed, falls through to default |
|
||||
| 21 | `frontend/src/pages/Notifications.tsx` | Token field visibility | Add `isNtfy` to `(isGotify \|\| isTelegram \|\| isSlack \|\| isPushover \|\| isNtfy)` |
|
||||
| 22 | `frontend/src/pages/Notifications.tsx` | Token field label | Add `isNtfy ? t('notificationProviders.ntfyAccessToken') : ...` |
|
||||
| 23 | `frontend/src/pages/Notifications.tsx` | URL placeholder | Add ntfy case: `type === 'ntfy' ? 'https://ntfy.sh/my-topic'` |
|
||||
| 24 | `frontend/src/pages/Notifications.tsx` | URL validation `required` | Ntfy requires URL — no change (default requires URL) |
|
||||
| 25 | `frontend/src/pages/Notifications.tsx` | URL validation `validate` | Ntfy uses standard URL validation — no change (default validates URL) |
|
||||
| 26 | `frontend/src/pages/Notifications.tsx` | `isNtfy` const | Add `const isNtfy = type === 'ntfy';` near L151 |
|
||||
| 27 | `frontend/src/pages/__tests__/Notifications.test.tsx` | Mock array | Add `'ntfy'` to mock `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` |
|
||||
| 28 | `tests/settings/notifications.spec.ts` | Provider type options assertion ~L297 | Change `toHaveCount(7)` → `toHaveCount(8)`, add `'Ntfy'` to `toHaveText()` array |
|
||||
|
||||
### 3.2 Backend Implementation Details
|
||||
|
||||
#### 3.2.1 Feature Flag
|
||||
|
||||
**File:** `backend/internal/notifications/feature_flags.go`
|
||||
|
||||
```go
|
||||
const FlagNtfyServiceEnabled = "feature.notifications.service.ntfy.enabled"
|
||||
```
|
||||
|
||||
#### 3.2.2 Router
|
||||
|
||||
**File:** `backend/internal/notifications/router.go`
|
||||
|
||||
Add in `ShouldUseNotify()` switch:
|
||||
|
||||
```go
|
||||
case "ntfy":
|
||||
return flags[FlagNtfyServiceEnabled]
|
||||
```
|
||||
|
||||
#### 3.2.3 Service — Type Registration
|
||||
|
||||
**File:** `backend/internal/services/notification_service.go`
|
||||
|
||||
In `isSupportedNotificationProviderType()`:
|
||||
|
||||
```go
|
||||
case "ntfy":
|
||||
return true
|
||||
```
|
||||
|
||||
In `isDispatchEnabled()`:
|
||||
|
||||
```go
|
||||
case "ntfy":
|
||||
return getFeatureFlagValue(db, notifications.FlagNtfyServiceEnabled, true)
|
||||
```
|
||||
|
||||
#### 3.2.4 Service — JSON Validation (sendJSONPayload)
|
||||
|
||||
In the service-specific validation block (~L460), add before the default case:
|
||||
|
||||
```go
|
||||
case "ntfy":
|
||||
if _, ok := payload["message"]; !ok {
|
||||
return fmt.Errorf("ntfy payload must include a 'message' field")
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Ntfy `priority` (1–5) can be set via custom templates by including a
|
||||
> `"priority"` field in the JSON. No code change is needed — the validation only
|
||||
> requires `"message"`.
|
||||
|
||||
#### 3.2.5 Service — supportsJSONTemplates + Outer Gating + Dispatch Routing
|
||||
|
||||
**supportsJSONTemplates()** (~L131): Add `"ntfy"` so `SendExternal()` dispatches
|
||||
via the JSON path:
|
||||
|
||||
```go
|
||||
case "ntfy":
|
||||
return true
|
||||
```
|
||||
|
||||
**Outer gating condition** (~L525): The dispatch block is entered only when the
|
||||
provider type matches an `if/else if` chain. The actual code uses `if` chains,
|
||||
**not** `switch/case`. Add ntfy:
|
||||
|
||||
```go
|
||||
// Before (actual code structure — NOT switch/case):
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" {
|
||||
|
||||
// After:
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" {
|
||||
```
|
||||
|
||||
**Dispatch routing** (~L540): Inside the dispatch block, add an ntfy branch
|
||||
using the same `if/else if` pattern as existing providers:
|
||||
|
||||
```go
|
||||
// Actual code uses if/else if — NOT switch/case:
|
||||
} else if providerType == "ntfy" {
|
||||
dispatchURL = p.URL
|
||||
if strings.TrimSpace(p.Token) != "" {
|
||||
headers["Authorization"] = "Bearer " + strings.TrimSpace(p.Token)
|
||||
}
|
||||
```
|
||||
|
||||
Then the existing `httpWrapper.Send(dispatchURL, headers, body)` call handles dispatch.
|
||||
|
||||
#### 3.2.6 Service — CreateProvider / UpdateProvider Token Preservation
|
||||
|
||||
**File:** `backend/internal/services/notification_service.go`
|
||||
|
||||
**`CreateProvider()` (~L851)** — token-clearing condition currently omits both
|
||||
ntfy and pushover, silently clearing tokens on creation:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" {
|
||||
provider.Token = ""
|
||||
}
|
||||
|
||||
// After (adds ntfy + fixes existing pushover bug):
|
||||
if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" && provider.Type != "pushover" && provider.Type != "ntfy" {
|
||||
provider.Token = ""
|
||||
}
|
||||
```
|
||||
|
||||
**`UpdateProvider()` (~L886)** — token preservation condition currently omits
|
||||
both ntfy and pushover, silently clearing tokens on update:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" {
|
||||
if strings.TrimSpace(provider.Token) == "" {
|
||||
provider.Token = existing.Token
|
||||
}
|
||||
} else {
|
||||
provider.Token = ""
|
||||
}
|
||||
|
||||
// After (adds ntfy + fixes existing pushover bug):
|
||||
if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" || provider.Type == "pushover" || provider.Type == "ntfy" {
|
||||
if strings.TrimSpace(provider.Token) == "" {
|
||||
provider.Token = existing.Token
|
||||
}
|
||||
} else {
|
||||
provider.Token = ""
|
||||
}
|
||||
```
|
||||
|
||||
> **Bonus bugfix:** The `pushover` additions fix a pre-existing bug where
|
||||
> pushover tokens were silently cleared on create and update. This will be noted
|
||||
> in the commit message for Commit 3.
|
||||
|
||||
#### 3.2.7 Handler — Type Validation + Token Preservation
|
||||
|
||||
**File:** `backend/internal/api/handlers/notification_provider_handler.go`
|
||||
|
||||
**`Create()` (~L185)** and **`Update()` (~L245)** type-validation chains:
|
||||
Add `&& providerType != "ntfy"` so ntfy passes the supported-type check.
|
||||
|
||||
**`Update()` token preservation (~L250)**: The handler has its own token
|
||||
preservation condition that runs before calling the service. Add ntfy:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover") && strings.TrimSpace(req.Token) == "" {
|
||||
req.Token = existing.Token
|
||||
}
|
||||
|
||||
// After:
|
||||
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy") && strings.TrimSpace(req.Token) == "" {
|
||||
req.Token = existing.Token
|
||||
}
|
||||
```
|
||||
|
||||
No URL validation special-case is needed for Ntfy (URL is required and follows
|
||||
standard http/https format).
|
||||
|
||||
### 3.3 Frontend Implementation Details
|
||||
|
||||
#### 3.3.1 API Client
|
||||
|
||||
**File:** `frontend/src/api/notifications.ts`
|
||||
|
||||
```typescript
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = [
|
||||
'discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy'
|
||||
] as const;
|
||||
```
|
||||
|
||||
In `sanitizeProviderForWriteAction()`, add `'ntfy'` to the set of token-bearing
|
||||
types so that the token field is properly mapped on create/update.
|
||||
|
||||
#### 3.3.2 Notifications Page
|
||||
|
||||
**File:** `frontend/src/pages/Notifications.tsx`
|
||||
|
||||
| Area | Change |
|
||||
|------|--------|
|
||||
| Type boolean | Add `const isNtfy = type === 'ntfy';` |
|
||||
| `<select>` | Add `<option value="ntfy">Ntfy</option>` after Pushover |
|
||||
| Token visibility | Change `(isGotify \|\| isTelegram \|\| isSlack \|\| isPushover)` to `(isGotify \|\| isTelegram \|\| isSlack \|\| isPushover \|\| isNtfy)` in 3 places: token field visibility, `normalizeProviderPayloadForSubmit()`, and `useEffect` token cleanup |
|
||||
| Token label | Add `isNtfy ? t('notificationProviders.ntfyAccessToken') : ...` in the ternary chain |
|
||||
| Token placeholder | Add ntfy case: `isNtfy ? t('notificationProviders.ntfyAccessTokenPlaceholder')` |
|
||||
| URL label | Consider using `t('notificationProviders.ntfyTopicUrl')` (`"Topic URL"`) for a more descriptive label when ntfy is selected, instead of the default `"URL / Webhook URL"` |
|
||||
| URL placeholder | Add `type === 'ntfy' ? 'https://ntfy.sh/my-topic'` in the ternary chain |
|
||||
| `supportsJSONTemplates()` | Add `|| t === 'ntfy'` |
|
||||
|
||||
#### 3.3.3 i18n Strings
|
||||
|
||||
**Files:** `frontend/src/locales/{en,de,fr,zh,es}/translation.json`
|
||||
|
||||
Add to the `notificationProviders` section (after `pushoverUserKeyHelp`):
|
||||
|
||||
| Key | English Value |
|
||||
|-----|---------------|
|
||||
| `certificates.bulkDeleteTitle` | `"Delete {{count}} Certificate(s)"` |
|
||||
| `certificates.bulkDeleteDescription` | `"Delete {{count}} certificate(s)"` |
|
||||
| `certificates.bulkDeleteConfirm` | `"The following certificates will be permanently deleted. The server creates a backup before each removal."` |
|
||||
| `certificates.bulkDeleteListAriaLabel` | `"Certificates to be deleted"` |
|
||||
| `certificates.bulkDeleteButton` | `"Delete {{count}} Certificate(s)"` |
|
||||
| `certificates.providerStaging` | `"Staging"` |
|
||||
| `certificates.providerCustom` | `"Custom"` |
|
||||
| `certificates.providerExpiredLE` | `"Expired LE"` |
|
||||
| `certificates.providerExpiringLE` | `"Expiring LE"` |
|
||||
| `common.cancel` | `"Cancel"` |
|
||||
| `ntfy` | `"Ntfy"` |
|
||||
| `ntfyAccessToken` | `"Access Token (optional)"` |
|
||||
| `ntfyAccessTokenPlaceholder` | `"Enter your Ntfy access token"` |
|
||||
| `ntfyAccessTokenHelp` | `"Required for password-protected topics on self-hosted instances. Not needed for public ntfy.sh topics. The token is stored securely and separately."` |
|
||||
| `ntfyTopicUrl` | `"Topic URL"` |
|
||||
|
||||
All keys exist in the translation file. No missing translations.
|
||||
For non-English locales, the keys should be added with English fallback values
|
||||
(the community can translate later).
|
||||
|
||||
### 2.5 Pattern Analysis — Other Test Files
|
||||
#### 3.3.4 Unit Test Mock + E2E Assertion Update
|
||||
|
||||
20+ test files have local `vi.mock('react-i18next')` overrides. Most use `t: (key) => key` and assert against raw keys — this is internally consistent and **not failing**. The `BulkDeleteCertificateDialog.test.tsx` file is unique because its **assertions expect translated values** while its mock returns raw keys.
|
||||
**File:** `frontend/src/pages/__tests__/Notifications.test.tsx`
|
||||
|
||||
| File | Local Mock | Assertions | Status |
|
||||
|------|-----------|------------|--------|
|
||||
| `CertificateList.test.tsx` | `t: (key) => key` | Raw keys (`certificates.deleteTitle`) | Passing |
|
||||
| `Certificates.test.tsx` | Custom translations map | Translated values | Passing |
|
||||
| `AccessLists.test.tsx` | Custom translations map | Translated values | Passing |
|
||||
| **BulkDeleteCertificateDialog.test.tsx** | `t: (key, opts) => opts ? JSON.stringify(opts) : key` | **Mix of translated values AND raw keys** | **Failing** |
|
||||
Update the mocked `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` array to include `'ntfy'`.
|
||||
Update the test `'shows supported provider type options'` to expect 8 options instead of 7.
|
||||
|
||||
**File:** `tests/settings/notifications.spec.ts`
|
||||
|
||||
Update the E2E assertion at ~L297:
|
||||
- `toHaveCount(7)` → `toHaveCount(8)`
|
||||
- Add `'Ntfy'` to the `toHaveText()` array: `['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram', 'Slack', 'Pushover', 'Ntfy']`
|
||||
|
||||
### 3.4 Database Migration
|
||||
|
||||
**No schema changes required.** The existing `NotificationProvider` GORM model
|
||||
already has all the fields Ntfy needs:
|
||||
|
||||
| Ntfy Concept | Model Field |
|
||||
|--------------|-------------|
|
||||
| Topic URL | `URL` |
|
||||
| Auth token | `Token` (json:"-") |
|
||||
| Has token indicator | `HasToken` (computed, gorm:"-") |
|
||||
|
||||
GORM AutoMigrate handles migrations from model definitions. No migration file
|
||||
is needed.
|
||||
|
||||
### 3.5 Data Flow Diagram
|
||||
|
||||
```
|
||||
User creates Ntfy provider via UI
|
||||
-> POST /api/v1/notifications/providers { type: "ntfy", url: "https://ntfy.sh/alerts", token: "tk_..." }
|
||||
-> Handler validates type is in allowed list
|
||||
-> Service stores provider in SQLite (token encrypted at rest)
|
||||
|
||||
Event triggers notification dispatch:
|
||||
-> SendExternal() filters enabled providers by event type preferences
|
||||
-> isDispatchEnabled("ntfy") -> checks FlagNtfyServiceEnabled setting
|
||||
-> sendJSONPayload() renders template -> validates payload has "message" field
|
||||
-> Constructs dispatch: POST to p.URL with Authorization: Bearer <token> header
|
||||
-> httpWrapper.Send(dispatchURL, headers, body) -> HTTP POST to Ntfy server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Root Cause Analysis
|
||||
## 4. Implementation Plan
|
||||
|
||||
**The local `vi.mock('react-i18next')` in `BulkDeleteCertificateDialog.test.tsx` returns raw translation keys, but the test assertions expect resolved English strings.**
|
||||
### Phase 1: Playwright E2E Tests (Test-First)
|
||||
|
||||
This is a mock/assertion mismatch introduced when the test was authored. The test expectations (`'Custom'`, `'Expiring LE'`) are correct for what the component should render, but the mock prevents translation resolution.
|
||||
Write E2E tests that define the expected UI/UX behavior for Ntfy before
|
||||
implementing the feature. Tests will initially fail and pass after implementation.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `tests/settings/ntfy-notification-provider.spec.ts` | New file — form rendering, CRUD, token security, field toggling |
|
||||
| `tests/settings/notifications-payload.spec.ts` | Add Ntfy to payload contract validation matrix |
|
||||
| `tests/settings/notifications.spec.ts` | Update provider type dropdown assertions: `toHaveCount(7)` → `toHaveCount(8)`, add `'Ntfy'` to `toHaveText()` array |
|
||||
|
||||
**Test structure** (following telegram/pushover/slack pattern):
|
||||
|
||||
1. Form Rendering
|
||||
- Show token field when ntfy type selected
|
||||
- Verify token label shows "Access Token (optional)"
|
||||
- Verify URL placeholder shows "https://ntfy.sh/my-topic"
|
||||
- Verify JSON template section is shown for ntfy
|
||||
- Toggle fields when switching between ntfy and discord
|
||||
2. CRUD Operations
|
||||
- Create ntfy provider with URL + token
|
||||
- Create ntfy provider with URL only (no token)
|
||||
- Edit ntfy provider (token field shows "Leave blank to keep")
|
||||
- Delete ntfy provider
|
||||
3. Token Security
|
||||
- Verify token field is `type="password"`
|
||||
- Verify token is not exposed in API response row
|
||||
4. Payload Contract
|
||||
- Valid ntfy payload with message field accepted
|
||||
- Missing message field rejected
|
||||
|
||||
### Phase 2: Backend Implementation
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
| # | File | Changes |
|
||||
|---|------|---------|
|
||||
| 1 | `backend/internal/notifications/feature_flags.go` | Add `FlagNtfyServiceEnabled` constant |
|
||||
| 2 | `backend/internal/notifications/router.go` | Add `"ntfy"` case in `ShouldUseNotify()` |
|
||||
| 3 | `backend/internal/services/notification_service.go` | Add `"ntfy"` to `isSupportedNotificationProviderType()`, `isDispatchEnabled()`, `supportsJSONTemplates()`, outer gating condition, dispatch routing, `CreateProvider()` token chain, `UpdateProvider()` token chain. Fix pushover token-clearing bug in same conditions. |
|
||||
| 4 | `backend/internal/api/handlers/notification_provider_handler.go` | Add `"ntfy"` to Create/Update type validation + Update token preservation |
|
||||
|
||||
**Backend Unit Tests:**
|
||||
|
||||
| File | New Tests |
|
||||
|------|-----------|
|
||||
| `backend/internal/notifications/router_test.go` | `TestShouldUseNotify_Ntfy` — flag on/off |
|
||||
| `backend/internal/services/notification_service_test.go` | `TestIsSupportedNotificationProviderType_Ntfy`, `TestIsDispatchEnabled_Ntfy` |
|
||||
| `backend/internal/services/notification_service_json_test.go` | `TestSendJSONPayload_Ntfy_Valid`, `TestSendJSONPayload_Ntfy_MissingMessage`, `TestSendJSONPayload_Ntfy_WithToken`, `TestSendJSONPayload_Ntfy_WithoutToken` |
|
||||
|
||||
### Phase 3: Frontend Implementation
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
| # | File | Changes |
|
||||
|---|------|---------|
|
||||
| 1 | `frontend/src/api/notifications.ts` | Add `'ntfy'` to type array + sanitize function |
|
||||
| 2 | `frontend/src/pages/Notifications.tsx` | Add `isNtfy`, dropdown option, token field wiring, URL placeholder, `supportsJSONTemplates()`, `normalizeProviderPayloadForSubmit()`, `useEffect` cleanup |
|
||||
| 3 | `frontend/src/locales/en/translation.json` | Add `ntfy*` i18n keys |
|
||||
| 4 | `frontend/src/locales/de/translation.json` | Add `ntfy*` i18n keys (English fallback) |
|
||||
| 5 | `frontend/src/locales/fr/translation.json` | Add `ntfy*` i18n keys (English fallback) |
|
||||
| 6 | `frontend/src/locales/zh/translation.json` | Add `ntfy*` i18n keys (English fallback) |
|
||||
| 7 | `frontend/src/locales/es/translation.json` | Add `ntfy*` i18n keys (English fallback) |
|
||||
| 8 | `frontend/src/pages/__tests__/Notifications.test.tsx` | Update mock array + option count assertion |
|
||||
|
||||
### Phase 4: Integration and Testing
|
||||
|
||||
1. Rebuild E2E Docker environment (`docker-rebuild-e2e`).
|
||||
2. Run full Playwright suite (Firefox, Chromium, WebKit).
|
||||
3. Run backend `go test ./...`.
|
||||
4. Run frontend `npm test`.
|
||||
5. Run GORM security scanner (changes touch service logic, not models — likely clean).
|
||||
6. Verify E2E coverage via Vite dev server mode.
|
||||
|
||||
### Phase 5: Documentation and Deployment
|
||||
|
||||
1. Update `docs/features.md` — add Ntfy to supported notification providers list.
|
||||
2. Update `CHANGELOG.md` — add `feat(notifications): add Ntfy notification provider`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Specification
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
### 4.1 Fix: Remove Local Mock, Update Assertions
|
||||
|
||||
**File:** `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx`
|
||||
|
||||
**Change 1 — Delete the local `vi.mock('react-i18next', ...)` block (lines 9–14)**
|
||||
|
||||
Removing this allows the global mock from `setup.ts` to take effect, which properly resolves translation keys to English values with interpolation.
|
||||
|
||||
**Change 2 — Update assertions that relied on the local mock's behavior**
|
||||
|
||||
With the global mock active, translation calls resolve differently:
|
||||
|
||||
| Call in component | Local mock output | Global mock output |
|
||||
|-------------------|-------------------|--------------------|
|
||||
| `t('certificates.bulkDeleteTitle', { count: 3 })` | `'{"count":3}'` | `'Delete 3 Certificate(s)'` |
|
||||
| `t('certificates.bulkDeleteButton', { count: 3 })` | `'{"count":3}'` | `'Delete 3 Certificate(s)'` |
|
||||
| `t('certificates.bulkDeleteButton', { count: 1 })` | `'{"count":1}'` | `'Delete 1 Certificate(s)'` |
|
||||
| `t('common.cancel')` | `'common.cancel'` | `'Cancel'` |
|
||||
| `t('certificates.providerCustom')` | `'certificates.providerCustom'` | `'Custom'` |
|
||||
| `t('certificates.providerExpiringLE')` | `'certificates.providerExpiringLE'` | `'Expiring LE'` |
|
||||
|
||||
Assertions to update:
|
||||
|
||||
| Line | Old Assertion | New Assertion |
|
||||
|------|---------------|---------------|
|
||||
| ~48 | `getByRole('heading', { name: '{"count":3}' })` | `getByRole('heading', { name: 'Delete 3 Certificate(s)' })` |
|
||||
| ~82 | `getByRole('button', { name: '{"count":3}' })` | `getByRole('button', { name: 'Delete 3 Certificate(s)' })` |
|
||||
| ~95 | `getByRole('button', { name: 'common.cancel' })` | `getByRole('button', { name: 'Cancel' })` |
|
||||
| ~109 | `getByRole('button', { name: '{"count":3}' })` | `getByRole('button', { name: 'Delete 3 Certificate(s)' })` |
|
||||
| ~111 | `getByRole('button', { name: 'common.cancel' })` | `getByRole('button', { name: 'Cancel' })` |
|
||||
|
||||
The currently-failing assertions (`getByText('Custom')`, `getByText('Expiring LE')`, etc.) will pass without changes once the global mock is active.
|
||||
|
||||
### 4.2 Config File Review
|
||||
|
||||
| File | Finding |
|
||||
|------|---------|
|
||||
| `.gitignore` | No changes needed. Test artifacts, coverage outputs, and CI logs are properly excluded. |
|
||||
| `codecov.yml` | No changes needed. Test files (`**/__tests__/**`, `**/*.test.tsx`) and test setup (`**/vitest.config.ts`, `**/vitest.setup.ts`) are already excluded from coverage. |
|
||||
| `.dockerignore` | No changes needed. Test artifacts and coverage files are excluded from Docker builds. |
|
||||
| `Dockerfile` | No changes needed. No test files are copied into the production image. |
|
||||
| # | Criterion | Validation Method |
|
||||
|---|-----------|-------------------|
|
||||
| AC-1 | User can select "Ntfy" from the provider type dropdown | E2E: `ntfy-notification-provider.spec.ts` form rendering tests |
|
||||
| AC-2 | Topic URL field is required with standard http/https validation | E2E: form validation tests |
|
||||
| AC-3 | Access Token field is shown as optional password field | E2E: token field visibility + type="password" check |
|
||||
| AC-4 | Token is never exposed in API responses (has_token indicator only) | E2E: token security tests |
|
||||
| AC-5 | JSON template section (minimal/detailed/custom) is available | E2E: template section visibility |
|
||||
| AC-6 | Ntfy provider can be created, edited, deleted | E2E: CRUD tests |
|
||||
| AC-7 | Test notification dispatches to Ntfy topic URL with correct headers | Backend unit test: sendJSONPayload ntfy dispatch |
|
||||
| AC-8 | Missing `message` field in payload is rejected | Backend unit test + E2E payload validation |
|
||||
| AC-9 | Feature flag `feature.notifications.service.ntfy.enabled` controls dispatch | Backend unit test: isDispatchEnabled + router |
|
||||
| AC-10 | All 5 locales have ntfy i18n keys | Manual verification |
|
||||
| AC-11 | No GORM security scanner CRITICAL/HIGH findings | GORM scanner `--check` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Phase 1: Fix the Test File
|
||||
|
||||
**Single file edit:** `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx`
|
||||
|
||||
1. Remove the local `vi.mock('react-i18next', ...)` block (lines 9–14)
|
||||
2. Update 5 assertion strings to use resolved English translations (see table in §4.1)
|
||||
3. No other files need changes
|
||||
|
||||
### Phase 2: Validation
|
||||
|
||||
1. Run the specific test file: `cd /projects/Charon/frontend && npx vitest run src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx`
|
||||
2. Run the full frontend test suite: `cd /projects/Charon/frontend && npx vitest run`
|
||||
3. Verify no regressions in other test files
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance Criteria
|
||||
|
||||
- [ ] Both failing tests pass: `lists each certificate name in the scrollable list` and `renders "Expiring LE" label for a letsencrypt cert with status expiring`
|
||||
- [ ] All 7 tests in `BulkDeleteCertificateDialog.test.tsx` pass
|
||||
- [ ] Full frontend test suite passes with no new failures
|
||||
- [ ] No local `vi.mock('react-i18next')` remains in `BulkDeleteCertificateDialog.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 7. Commit Slicing Strategy
|
||||
## 6. Commit Slicing Strategy
|
||||
|
||||
### Decision: Single PR
|
||||
|
||||
**Rationale:** This is a single-file fix with no cross-domain changes, no schema changes, no API changes, and no risk of affecting other components. The change is purely correcting assertion/mock alignment in one test file.
|
||||
**Rationale:** Ntfy is a self-contained, additive feature that does not touch
|
||||
existing provider logic (only adds new cases to existing switch/case and if-chain
|
||||
blocks). The changeset is small (~16 files, <300 lines of implementation + ~430
|
||||
lines of tests) and stays within a single domain (notifications). A single PR is
|
||||
straightforward to review and rollback. One bonus bugfix is included: pushover
|
||||
token-clearing in `CreateProvider()`/`UpdateProvider()` is fixed in the same
|
||||
lines being modified for ntfy.
|
||||
|
||||
### PR-1: Fix BulkDeleteCertificateDialog i18n test mock
|
||||
**Trigger analysis:**
|
||||
- Scope: Small — one new provider, no schema changes, no new packages.
|
||||
- Risk: Low — all changes are additive `case`/`if` additions; the only behavior change to existing providers is fixing the pushover token-clearing bug (a correctness fix).
|
||||
- Cross-domain: No — backend + frontend are in the same PR (standard for features).
|
||||
- Review size: Moderate — well within single-PR comfort zone.
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| **Scope** | Remove local i18n mock override, update 5 assertions |
|
||||
| **Files** | `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` |
|
||||
| **Dependencies** | None |
|
||||
| **Validation Gate** | All 7 tests in the file pass; full frontend suite green |
|
||||
| **Rollback** | Revert single commit |
|
||||
### Ordered Commits
|
||||
|
||||
### Contingency
|
||||
| Commit | Scope | Files | Validation Gate |
|
||||
|--------|-------|-------|-----------------|
|
||||
| `1` | `test(e2e): add Ntfy notification provider E2E tests` | `tests/settings/ntfy-notification-provider.spec.ts`, `tests/settings/notifications-payload.spec.ts`, `tests/settings/notifications.spec.ts` | Tests compile (expected to fail until implementation) |
|
||||
| `2` | `feat(notifications): add Ntfy feature flag and router support` | `feature_flags.go`, `router.go`, `router_test.go` | `go test ./backend/internal/notifications/...` passes |
|
||||
| `3` | `fix(notifications): add Ntfy dispatch + fix pushover/ntfy token-clearing bug` | `notification_service.go`, `notification_service_json_test.go`, `notification_service_test.go` | `go test ./backend/internal/services/...` passes |
|
||||
| `4` | `feat(notifications): add Ntfy type validation to handlers` | `notification_provider_handler.go` | `go test ./backend/internal/api/handlers/...` passes |
|
||||
| `5` | `feat(notifications): add Ntfy frontend support` | `notifications.ts`, `Notifications.tsx`, `Notifications.test.tsx`, all 5 locale files | `npm test` passes; full Playwright suite passes |
|
||||
| `6` | `docs: add Ntfy to features and changelog` | `docs/features.md`, `CHANGELOG.md` | No tests needed |
|
||||
|
||||
If the global mock from `setup.ts` does not resolve all keys correctly (unlikely given the translation JSON analysis), the fallback is to replace the local mock with a custom translations map pattern (as used in `AccessLists.test.tsx` and `Certificates.test.tsx`) containing the exact keys needed by this component.
|
||||
### Rollback
|
||||
|
||||
Reverting the PR removes all Ntfy cases from switch/case blocks. No data
|
||||
migration reversal needed (model is unchanged). Any Ntfy providers created by
|
||||
users during the rollout window would remain in the database as orphan rows
|
||||
(type `"ntfy"` would be rejected by the handler validation, effectively
|
||||
disabling them).
|
||||
|
||||
---
|
||||
|
||||
## 7. Review Suggestions for Build / Config Files
|
||||
|
||||
### `.gitignore`
|
||||
|
||||
No changes needed. The current `.gitignore` correctly covers all relevant
|
||||
artifact patterns. No Ntfy-specific files are introduced.
|
||||
|
||||
### `codecov.yml`
|
||||
|
||||
No changes needed. The current `ignore` patterns correctly exclude test files,
|
||||
docs, and config. The 87% project coverage target and 1% threshold remain
|
||||
appropriate.
|
||||
|
||||
### `.dockerignore`
|
||||
|
||||
No changes needed. The current `.dockerignore` mirrors `.gitignore` patterns
|
||||
appropriately. No new directories or file types are introduced.
|
||||
|
||||
### `Dockerfile`
|
||||
|
||||
No changes needed. The multi-stage build already compiles the full Go backend
|
||||
and React frontend — adding a new provider type requires no build-system changes.
|
||||
No new dependencies are introduced.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Ntfy server unreachable | Low | Low | Standard HTTP timeout via `httpWrapper.Send()` (existing 10s timeout) |
|
||||
| Token leaked in logs | Low | High | Token field is `json:"-"` in model; dispatch uses `headers` map (not logged). Verify no debug logging of headers. |
|
||||
| SSRF via topic URL | Low | High | Ntfy matches the SSRF posture of Gotify and webhook (user-controlled URL), **not** Telegram (which pins to a hardcoded `api.telegram.org` base). `httpWrapper.Send()` applies the existing 10s timeout but no URL allowlist. Risk is **accepted** for parity with Gotify/webhook; a future hardening pass should apply `ValidateExternalURL` to all user-controlled URL providers. |
|
||||
| Breaking existing providers | Very Low | High | All changes are additive `case` blocks — no existing behavior modified. Full regression suite via Playwright. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix: File Inventory
|
||||
|
||||
Complete list of files to create or modify:
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `tests/settings/ntfy-notification-provider.spec.ts` | E2E test suite for Ntfy provider |
|
||||
|
||||
### Modified Files — Backend
|
||||
|
||||
| File | Lines Changed (est.) |
|
||||
|------|---------------------|
|
||||
| `backend/internal/notifications/feature_flags.go` | +1 |
|
||||
| `backend/internal/notifications/router.go` | +2 |
|
||||
| `backend/internal/notifications/router_test.go` | +15 |
|
||||
| `backend/internal/services/notification_service.go` | +18 |
|
||||
| `backend/internal/services/notification_service_test.go` | +20 |
|
||||
| `backend/internal/services/notification_service_json_test.go` | +60 |
|
||||
| `backend/internal/api/handlers/notification_provider_handler.go` | +3 |
|
||||
|
||||
### Modified Files — Frontend
|
||||
|
||||
| File | Lines Changed (est.) |
|
||||
|------|---------------------|
|
||||
| `frontend/src/api/notifications.ts` | +3 |
|
||||
| `frontend/src/pages/Notifications.tsx` | +15 |
|
||||
| `frontend/src/pages/__tests__/Notifications.test.tsx` | +3 |
|
||||
| `frontend/src/locales/en/translation.json` | +5 |
|
||||
| `frontend/src/locales/de/translation.json` | +5 |
|
||||
| `frontend/src/locales/fr/translation.json` | +5 |
|
||||
| `frontend/src/locales/zh/translation.json` | +5 |
|
||||
| `frontend/src/locales/es/translation.json` | +5 |
|
||||
|
||||
### Modified Files — Tests
|
||||
|
||||
| File | Lines Changed (est.) |
|
||||
|------|---------------------|
|
||||
| `tests/settings/notifications-payload.spec.ts` | +30 |
|
||||
| `tests/settings/notifications.spec.ts` | +2 |
|
||||
|
||||
### Modified Files — Documentation
|
||||
|
||||
| File | Lines Changed (est.) |
|
||||
|------|---------------------|
|
||||
| `docs/features.md` | +1 |
|
||||
| `CHANGELOG.md` | +1 |
|
||||
|
||||
**Total estimated implementation:** ~195 lines (backend + frontend) + ~430 lines (tests)
|
||||
|
||||
@@ -1,609 +1,322 @@
|
||||
# QA Security Audit Report — CWE-614 Remediation
|
||||
|
||||
**Date:** 2026-03-21
|
||||
**Scope:** `backend/internal/api/handlers/auth_handler.go` — removal of `secure = false` branch from `setSecureCookie`
|
||||
**Audited by:** QA Security Agent
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Backend-only change. File audited:
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `backend/internal/api/handlers/auth_handler.go` | Modified — `secure = false` branch removed; `Secure` always `true` |
|
||||
| `backend/internal/api/handlers/auth_handler_test.go` | Modified — all `TestSetSecureCookie_*` assertions updated to `assert.True(t, cookie.Secure)` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Test Results
|
||||
|
||||
| Metric | Value | Gate | Status |
|
||||
|---|---|---|---|
|
||||
| Statement coverage | 88.0% | ≥ 87% | ✅ PASS |
|
||||
| Line coverage | 88.2% | ≥ 87% | ✅ PASS |
|
||||
| Test failures | 0 | 0 | ✅ PASS |
|
||||
|
||||
All `TestSetSecureCookie_*` variants assert `cookie.Secure == true` unconditionally, correctly reflecting the remediated behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 2. Lint Results
|
||||
|
||||
**Tool:** `golangci-lint` (fast config — staticcheck, govet, errcheck, ineffassign, unused)
|
||||
|
||||
**Result:** `0 issues` — ✅ PASS
|
||||
|
||||
---
|
||||
|
||||
## 3. Pre-commit Hooks
|
||||
|
||||
**Tool:** Lefthook v2.1.4
|
||||
|
||||
| Hook | Result |
|
||||
|---|---|
|
||||
| check-yaml | ✅ PASS |
|
||||
| actionlint | ✅ PASS |
|
||||
| end-of-file-fixer | ✅ PASS |
|
||||
| trailing-whitespace | ✅ PASS |
|
||||
| dockerfile-check | ✅ PASS |
|
||||
| shellcheck | ✅ PASS |
|
||||
|
||||
Go-specific hooks (`go-vet`, `golangci-lint-fast`) were skipped — no staged files. These were validated directly via `make lint-fast`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Trivy Security Scan
|
||||
|
||||
**Tool:** Trivy v0.52.2
|
||||
|
||||
### New Vulnerabilities Introduced by This Change
|
||||
|
||||
**None.** Zero HIGH or CRITICAL vulnerabilities attributable to the CWE-614 remediation.
|
||||
|
||||
### Pre-existing Baseline Finding (unrelated)
|
||||
|
||||
| ID | Severity | Type | Description |
|
||||
|---|---|---|---|
|
||||
| DS002 | HIGH | Dockerfile misconfiguration | Container runs as root — pre-existing, not introduced by this change |
|
||||
|
||||
---
|
||||
|
||||
## 5. CWE-614 Verification
|
||||
|
||||
### Pattern Search: `secure = false` in handlers package
|
||||
|
||||
```
|
||||
grep -rn "secure = false" /projects/Charon/backend/
|
||||
```
|
||||
|
||||
**Result:** 0 matches — ✅ CLEARED
|
||||
|
||||
### Pattern Search: Inline CodeQL suppression
|
||||
|
||||
```
|
||||
grep -rn "codeql[go/cookie-secure-not-set]" /projects/Charon/backend/
|
||||
```
|
||||
|
||||
**Result:** 0 matches — ✅ CLEARED
|
||||
|
||||
### `setSecureCookie` Implementation
|
||||
|
||||
The function unconditionally passes `true` as the `secure` argument to `c.SetCookie`:
|
||||
|
||||
```go
|
||||
c.SetCookie(
|
||||
name, // name
|
||||
value, // value
|
||||
maxAge, // maxAge in seconds
|
||||
"/", // path
|
||||
domain, // domain (empty = current host)
|
||||
true, // secure ← always true, no conditional branch
|
||||
true, // httpOnly
|
||||
)
|
||||
```
|
||||
|
||||
All test cases (`TestSetSecureCookie_HTTPS_Strict`, `_HTTP_Lax`, `_HTTP_Loopback_Insecure`,
|
||||
`_ForwardedHTTPS_*`, `_HTTP_PrivateIP_Insecure`, `_HTTP_10Network_Insecure`,
|
||||
`_HTTP_172Network_Insecure`) assert `cookie.Secure == true`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---|---|
|
||||
| Backend unit tests | ✅ PASS | 0 failures, 88.0% coverage (gate: 87%) |
|
||||
| Lint | ✅ PASS | 0 issues |
|
||||
| Pre-commit hooks | ✅ PASS | All 6 active hooks passed |
|
||||
| Trivy | ✅ PASS | No new HIGH/CRITICAL vulns |
|
||||
| `secure = false` removed | ✅ CLEARED | 0 matches in handlers package |
|
||||
| CodeQL suppression removed | ✅ CLEARED | 0 matches in handlers package |
|
||||
|
||||
---
|
||||
|
||||
## Overall: ✅ PASS
|
||||
|
||||
The CWE-614 remediation is complete and correct. All cookies set by `setSecureCookie` now unconditionally carry `Secure = true`. No regressions, no new security findings, and coverage remains above the required threshold.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- Previous reports archived below -->
|
||||
|
||||
# QA Audit Report — PR-1: Allow Empty Value in UpdateSetting
|
||||
|
||||
**Date:** 2026-03-17
|
||||
**Scope:** Remove `binding:"required"` from `Value` field in `UpdateSettingRequest`
|
||||
**File:** `backend/internal/api/handlers/settings_handler.go`
|
||||
|
||||
---
|
||||
|
||||
# QA Security Audit Report — Rate Limit CI Fix
|
||||
|
||||
**Audited by**: QA Security Auditor
|
||||
**Date**: 2026-03-17
|
||||
**Spec reference**: `docs/plans/rate_limit_ci_fix_spec.md`
|
||||
**Files audited**:
|
||||
- `scripts/rate_limit_integration.sh`
|
||||
- `Dockerfile` (GeoIP section, non-CI path)
|
||||
- `.github/workflows/rate-limit-integration.yml`
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Check Results
|
||||
|
||||
| Check | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| Bash syntax | `bash -n scripts/rate_limit_integration.sh` | ✅ PASS (exit 0) |
|
||||
| Pre-commit hooks | `lefthook run pre-commit` (project uses lefthook; no `.pre-commit-config.yaml`) | ✅ PASS — all 6 hooks passed: `check-yaml`, `actionlint`, `end-of-file-fixer`, `trailing-whitespace`, `dockerfile-check`, `shellcheck` |
|
||||
| Caddy admin API trailing slash (workflow) | `grep -n "2119" .github/workflows/rate-limit-integration.yml` | ✅ PASS — line 71 references `/config/` (trailing slash present) |
|
||||
| Caddy admin API trailing slash (script) | All 6 occurrences of `localhost:2119/config` in script | ✅ PASS — all use `/config/` |
|
||||
|
||||
---
|
||||
|
||||
## Security Focus Area Results
|
||||
|
||||
### 1. Credential Handling — `TMP_COOKIE`
|
||||
|
||||
**`mktemp` usage**: `TMP_COOKIE=$(mktemp)` at line 208. Creates a file in `/tmp` with `600` permissions via the OS. ✅ SECURE.
|
||||
|
||||
**Removal on exit**: The `cleanup()` function at line 103 removes the file with `rm -f "${TMP_COOKIE:-}"`. However, `cleanup` is only registered via explicit calls — there is **no `trap cleanup EXIT`**. Only `trap on_failure ERR` is registered (line 108).
|
||||
|
||||
**Gap**: On 5 early `exit 1` paths after line 208 (login failure L220, auth failure L251, Caddy readiness failure L282, security config failure L299, and handler verification failure L316), `cleanup` is never called. The cookie file is left in `/tmp`.
|
||||
|
||||
**Severity**: LOW — The cookie contains session credentials for a localhost test server (`ratelimit@example.local` / `password123`, non-production). CI runners are ephemeral and auto-cleaned. Local runs will leave a `/tmp/tmp.XXXXXX` file until next reboot or manual cleanup.
|
||||
|
||||
**Note**: The exit at line 386 (inside the 429 enforcement failure block) intentionally skips cleanup to leave containers running for manual inspection. This is by design and acceptable.
|
||||
|
||||
**Recommendation**: Add `trap cleanup EXIT` immediately after `trap on_failure ERR` (line 109) to ensure the cookie file is always removed.
|
||||
|
||||
---
|
||||
|
||||
### 2. `curl` — Sensitive Values in Command-Line Arguments
|
||||
|
||||
Cookie file path is passed via `-c ${TMP_COOKIE}` and `-b ${TMP_COOKIE}` (unquoted). No credentials, tokens, or API keys are passed as command-line arguments. All authentication is via the cookie file (read/write by path), which is the correct pattern — cookie values never appear in `ps` output.
|
||||
|
||||
**Finding (LOW)**: `${TMP_COOKIE}` is unquoted in all 6 curl invocations. `mktemp` on Linux produces paths of the form `/tmp/tmp.XXXXXX` which never contain spaces or shell metacharacters under default `$TMPDIR`. However, under a non-standard `$TMPDIR` (e.g., `/tmp/my dir/`) this would break. This is a portability issue, not a security issue.
|
||||
|
||||
**Recommendation**: Quote `"${TMP_COOKIE}"` in all curl invocations.
|
||||
|
||||
---
|
||||
|
||||
### 3. Shell Injection
|
||||
|
||||
All interpolated values in curl `-d` payloads are either:
|
||||
- Script-level constants (`RATE_LIMIT_REQUESTS=3`, `RATE_LIMIT_WINDOW_SEC=10`, `RATE_LIMIT_BURST=1`, `TEST_DOMAIN=ratelimit.local`, `BACKEND_CONTAINER=ratelimit-backend`)
|
||||
- Values derived from API responses stored in double-quoted variables (`"$CREATE_RESP"`, `"$SEC_CONFIG_RESP"`)
|
||||
|
||||
No shell injection vector exists. All heredoc expansions (`cat <<EOF...EOF`) expand only the hardcoded constants listed above.
|
||||
|
||||
The UUID extraction pattern at line 429 includes `${TEST_DOMAIN}` unquoted within a `grep -o` pattern, but because the variable expands to `ratelimit.local` (controlled constant), this has no injection risk. The `.` in `ratelimit.local` is treated as a regex wildcard but in this context only matches the intended hostname. ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
### 4. `set -euo pipefail` Compatibility
|
||||
|
||||
The new status-capture idiom:
|
||||
|
||||
```bash
|
||||
LOGIN_STATUS=$(curl -s -w "\n%{http_code}" ... | tail -n1)
|
||||
```
|
||||
|
||||
Behavior under `set -euo pipefail`:
|
||||
- **Network failure** (curl exits non-zero, e.g., `ECONNREFUSED`): `pipefail` propagates curl's non-zero exit through the pipeline; the assignment fails; `set -e` fires the `on_failure` ERR trap and exits. ✅ Correct.
|
||||
- **HTTP error** (curl exits 0, HTTP 4xx/5xx): curl outputs `\n{code}`; `tail -n1` extracts the code; assignment succeeds; subsequent `[ "$LOGIN_STATUS" != "200" ]` detects the failure. ✅ Correct.
|
||||
- **Empty body edge case**: If curl returns an empty body, output is `\n200`. `tail -n1` → `200`; `head -n-1` → empty string. Status check still works. ✅ Correct.
|
||||
|
||||
The `SEC_CONFIG_RESP` split pattern (`tail -n1` for status, `head -n-1` for body) is correct for both single-line and multiline JSON responses. ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
### 5. Workflow Secrets Exposure
|
||||
|
||||
The workflow (`rate-limit-integration.yml`) contains **no `${{ secrets.* }}` references**. All test credentials are hardcoded constants in the script (`ratelimit@example.local` / `password123`), appropriate for an ephemeral test user that is registered and used only within the test run.
|
||||
|
||||
`$GITHUB_STEP_SUMMARY` output includes: container status, API config JSON, container logs. None of these contain secrets or credentials. The security config JSON may contain rate limit settings (integers) but nothing sensitive.
|
||||
|
||||
No accidental log exposure identified. ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
### 6. GeoIP Change — Supply-Chain Risk
|
||||
|
||||
**Change**: The non-CI Dockerfile build path previously ran `sha256sum -c -` against `GEOLITE2_COUNTRY_SHA256`. This was removed. The remaining guard is `[ -s /app/data/geoip/GeoLite2-Country.mmdb ]` (file-size non-empty check).
|
||||
|
||||
**Risk assessment** (MEDIUM): The download source is `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb`, a public GitHub repository. If this repository is compromised or the file is replaced with a malicious binary:
|
||||
- The `-s` check only verifies the file is non-empty
|
||||
- The application loads it at `CHARON_GEOIP_DB_PATH` for IP geolocation — a non-privileged read operation
|
||||
- A malicious file would not achieve RCE via MMDb parsing in the MaxMind reader library (no known attack surface), but could corrupt GeoIP lookups silently
|
||||
|
||||
**This is an acknowledged, pre-existing architectural limitation** documented in the spec. The `sha256sum` check was ineffective by design because the P3TERX repository updates the file continuously while the pinned hash only updates weekly via `update-geolite2.yml`. The new behavior (accept any non-empty file) is more honest about the actual constraint.
|
||||
|
||||
**Spec compliance**: `ARG GEOLITE2_COUNTRY_SHA256` is **retained** in the Dockerfile (line ~441) as required by the spec, preserving `update-geolite2.yml` workflow compatibility. ✅ PASS.
|
||||
|
||||
**Residual risk**: MEDIUM. Mitigated by: (1) `wget` uses HTTPS to fetch from GitHub (TLS in transit), (2) downstream Trivy scans of the built image would flag a malicious MMDB independently, (3) the GeoIP reader is sandboxed to a read operation with no known parse-exploit surface.
|
||||
|
||||
---
|
||||
|
||||
## Correctness Against Spec
|
||||
|
||||
| Spec Change | Implemented | Verified |
|
||||
|-------------|-------------|----------|
|
||||
| C1: Login status check (Step 4) | ✅ Yes — `LOGIN_STATUS` checked, fails fast on non-200 | Script lines 211–220 |
|
||||
| C2: Proxy host creation — auth failures fatal, 409 continues | ✅ Yes — 401/403 abort, other non-201 continues | Script lines 248–256 |
|
||||
| C3: Caddy admin API readiness gate before security config POST | ✅ Yes — 20-retry loop before SEC_CFG call | Script lines 274–284 |
|
||||
| C4: Security config POST status checked | ✅ Yes — `SEC_CONFIG_STATUS` checked, body logged on error | Script lines 286–301 |
|
||||
| C5: `verify_rate_limit_config` failure is hard exit | ✅ Yes — prints debug and `exit 1` | Script lines 307–318 |
|
||||
| C6: Pre-verification sleep increased 5 → 8 s | ✅ Yes — `sleep 8` | Script line 305 |
|
||||
| C7: Trailing slash on `/config/` | ✅ Yes — all 6 script occurrences; workflow line 71 | Confirmed by grep |
|
||||
| Dockerfile: sha256sum removed from non-CI path | ✅ Yes — only `-s` check remains | Dockerfile lines ~453–463 |
|
||||
| Dockerfile: `ARG GEOLITE2_COUNTRY_SHA256` retained | ✅ Yes — line ~441 | Dockerfile audited |
|
||||
| Workflow: debug dump uses `/config/` | ✅ Yes — line 71 | Confirmed by grep |
|
||||
|
||||
---
|
||||
|
||||
## Findings Summary
|
||||
|
||||
| ID | Severity | Area | Description |
|
||||
|----|----------|------|-------------|
|
||||
| M1 | MEDIUM | Dockerfile supply-chain | GeoIP downloaded without hash; `-s` is minimum viability only. Accepted trade-off per spec — hash was perpetually stale. |
|
||||
| L1 | LOW | Shell security | `${TMP_COOKIE}` unquoted in 6 curl invocations. No practical impact under standard `$TMPDIR`. |
|
||||
| L2 | LOW | Temp file hygiene | No `trap cleanup EXIT`; TMP_COOKIE and containers not cleaned on 5 early failure paths (lines 220, 251, 282, 299, 316). Low sensitivity (localhost test credentials only). |
|
||||
|
||||
No CRITICAL or HIGH severity findings.
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
**✅ APPROVED**
|
||||
|
||||
All spec-required changes are correctly implemented. No OWASP Top 10 vulnerabilities were introduced. The two LOW findings (unquoted variable, missing EXIT trap) are hygiene improvements that do not block the fix. The MEDIUM GeoIP supply-chain concern is a pre-existing architectural trade-off explicitly acknowledged in the spec.
|
||||
|
||||
### Recommended follow-up (non-blocking)
|
||||
|
||||
Add `trap cleanup EXIT` immediately after `trap on_failure ERR` in `scripts/rate_limit_integration.sh` to ensure TMP_COOKIE is always removed and containers are cleaned on all exit paths.
|
||||
**Purpose:** Allow admins to set a setting to an empty string value (required to fix the fresh-install CrowdSec enabling bug where `value` was legitimately empty).
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict: APPROVED
|
||||
|
||||
All structural, linting, and security gates pass. The change is correctly scoped to the build-only `frontend-builder` stage and introduces no new attack surface in the final runtime image.
|
||||
|
||||
---
|
||||
|
||||
## Changes Under Review
|
||||
|
||||
| Element | Location | Description |
|
||||
|---|---|---|
|
||||
| `ARG NPM_VERSION=11.11.1` | Line 30 (global ARG block) | Pinned npm version with Renovate comment |
|
||||
| `ARG NPM_VERSION` | Line 105 (frontend-builder) | Bare re-declaration to inherit global ARG into stage |
|
||||
| `# hadolint ignore=DL3017` | Line 106 | Lint suppression for intentional `apk upgrade` |
|
||||
| `RUN apk upgrade --no-cache && ...` | Lines 107–109 | Three-command RUN: OS patch + npm upgrade + cache clear |
|
||||
| `RUN npm ci` | Line 111 | Unchanged dependency install follows the new RUN block |
|
||||
|
||||
---
|
||||
|
||||
## Gate Summary
|
||||
|
||||
| # | Gate | Result | Details |
|
||||
|---|---|---|---|
|
||||
| 1 | Global `ARG NPM_VERSION` present with Renovate comment | **PASS** | Line 30; `# renovate: datasource=npm depName=npm` at line 29 |
|
||||
| 2 | `ARG NPM_VERSION` bare re-declaration inside stage | **PASS** | Line 105 |
|
||||
| 3 | `# hadolint ignore=DL3017` on own line before RUN block | **PASS** | Line 106 |
|
||||
| 4 | RUN block — three correct commands | **PASS** | Lines 107–109: `apk upgrade --no-cache`, `npm install -g npm@${NPM_VERSION} --no-fund --no-audit`, `npm cache clean --force` |
|
||||
| 5 | `RUN npm ci` still present and follows new block | **PASS** | Line 111 |
|
||||
| 6 | FROM line unchanged | **PASS** | `node:24.14.0-alpine@sha256:7fddd9ddeae8196abf4a3ef2de34e11f7b1a722119f91f28ddf1e99dcafdf114` |
|
||||
| 7 | `${NPM_VERSION}` used (no hard-coded version) | **PASS** | Confirmed variable reference in install command |
|
||||
| 8 | Trivy config scan (HIGH/CRITICAL) | **PASS** | 0 misconfigurations |
|
||||
| 9 | Hadolint (new code area) | **PASS** | No errors or warnings; only pre-existing `info`-level DL3059 at unrelated lines |
|
||||
| 10 | Runtime image isolation | **PASS** | Only `/app/frontend/dist` artifacts copied into final image via line 535 |
|
||||
| 11 | `--no-audit` acceptability | **PASS** | Applies only to the single-package global npm upgrade; `npm ci` is unaffected |
|
||||
| 12 | `npm cache clean --force` safety | **PASS** | Safe cache clear between npm tool upgrade and dependency install |
|
||||
|
||||
---
|
||||
|
||||
## 1. Dockerfile Structural Verification
|
||||
|
||||
### Global ARG block (lines 25–40)
|
||||
|
||||
```
|
||||
29: # renovate: datasource=npm depName=npm
|
||||
30: ARG NPM_VERSION=11.11.1
|
||||
```
|
||||
|
||||
Both the Renovate comment and the pinned ARG are present in the correct order. Renovate will track `npm` releases on `datasource=npm` and propose version bumps automatically.
|
||||
|
||||
### frontend-builder stage (lines 93–115)
|
||||
|
||||
```
|
||||
93: FROM --platform=$BUILDPLATFORM node:24.14.0-alpine@sha256:... AS frontend-builder
|
||||
...
|
||||
105: ARG NPM_VERSION
|
||||
106: # hadolint ignore=DL3017
|
||||
107: RUN apk upgrade --no-cache && \
|
||||
108: npm install -g npm@${NPM_VERSION} --no-fund --no-audit && \
|
||||
109: npm cache clean --force
|
||||
...
|
||||
111: RUN npm ci
|
||||
```
|
||||
|
||||
All structural requirements confirmed: bare re-declaration, lint suppression on dedicated line, three-command RUN, and unmodified `npm ci`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Security Tool Results
|
||||
|
||||
### Trivy config scan
|
||||
|
||||
**Command:** `docker run aquasec/trivy config Dockerfile --severity HIGH,CRITICAL`
|
||||
|
||||
```
|
||||
Report Summary
|
||||
┌────────────┬────────────┬───────────────────┐
|
||||
│ Target │ Type │ Misconfigurations │
|
||||
├────────────┼────────────┼───────────────────┤
|
||||
│ Dockerfile │ dockerfile │ 0 │
|
||||
└────────────┴────────────┴───────────────────┘
|
||||
```
|
||||
|
||||
No HIGH or CRITICAL misconfigurations detected.
|
||||
|
||||
### Hadolint
|
||||
|
||||
**Command:** `docker run hadolint/hadolint < Dockerfile`
|
||||
|
||||
Findings affecting the new code: **none**.
|
||||
|
||||
Pre-existing `info`-level findings (unrelated to this change):
|
||||
|
||||
| Line | Rule | Message |
|
||||
|---|---|---|
|
||||
| 78, 81, 137, 335, 338 | DL3059 info | Multiple consecutive RUN — pre-existing pattern |
|
||||
| 492 | SC2012 info | Use `find` instead of `ls` — unrelated |
|
||||
|
||||
No errors or warnings in the `frontend-builder` section.
|
||||
|
||||
---
|
||||
|
||||
## 3. Logical Security Review
|
||||
|
||||
### Attack surface — build-only stage
|
||||
|
||||
The `frontend-builder` stage is strictly a build artifact producer. The final runtime image receives only compiled frontend assets via a single targeted `COPY`:
|
||||
|
||||
```
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
||||
```
|
||||
|
||||
The Alpine OS packages upgraded by `apk upgrade --no-cache`, the globally installed npm binary, and all `node_modules` are confined to the builder layer and never reach the runtime image. The CVE remediation has zero footprint in the deployed container.
|
||||
|
||||
### `--no-audit` flag
|
||||
|
||||
`--no-audit` suppresses npm audit output during `npm install -g npm@${NPM_VERSION}`. This applies only to the single-package global npm tool upgrade, not to the project dependency installation. `npm ci` on line 111 installs project dependencies from `package-lock.json` and is unaffected by this flag. Suppressing audit during a build-time tool upgrade is the standard pattern for avoiding advisory database noise that cannot be acted on during the image build.
|
||||
|
||||
### `npm cache clean --force`
|
||||
|
||||
Clears the npm package cache between the global npm upgrade and the `npm ci` run. This is safe: it ensures the freshly installed npm binary is used without stale cache entries left by the older npm version bundled in the base image. The `--force` flag suppresses npm's deprecation warning about manual cache cleaning; it does not alter the clean operation itself.
|
||||
|
||||
---
|
||||
|
||||
## Blocking Issues
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
# Supply Chain Security Scan Report — CVE Investigation
|
||||
|
||||
**Date**: 2026-03-19
|
||||
**Scope**: Charon project at `/projects/Charon`
|
||||
**Tools**: Grype 0.109.1, Syft 1.42.2
|
||||
**Go Toolchain**: go1.26.1
|
||||
# QA Security Audit Report
|
||||
|
||||
| Field | Value |
|
||||
|-------------|--------------------------------|
|
||||
| **Date** | 2026-03-24 |
|
||||
| **Image** | `charon:local` (Alpine 3.23.3) |
|
||||
| **Go** | 1.26.1 |
|
||||
| **Grype** | 0.110.0 |
|
||||
| **Trivy** | 0.69.1 |
|
||||
| **CodeQL** | Latest (SARIF v2.1.0) |
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The CVEs flagged for `goxmldsig`, `buger/jsonparser`, and `jackc/pgproto3/v2` are **false positives for the Charon project**. These packages are not in Charon's Go module dependency graph. They originate from Go build info embedded in third-party compiled binaries shipped inside the Docker image — specifically the CrowdSec and Caddy binaries.
|
||||
The current `charon:local` image built on 2026-03-24 shows a significantly improved
|
||||
security posture compared to the CI baseline. Three previously tracked SECURITY.md
|
||||
vulnerabilities are now **resolved** due to Go 1.26.1 compilation and Alpine package
|
||||
updates. Two new medium/low findings emerged. No CRITICAL or HIGH active
|
||||
vulnerabilities remain in the unignored scan results.
|
||||
|
||||
`CVE-2026-33186` (`google.golang.org/grpc`) is **resolved in Charon's own source code** (bumped to v1.79.3), but the same CVE still appears in the SBOM because older grpc versions are embedded in the CrowdSec (`v1.74.2`) and Caddy (`v1.79.1`) binaries in the Docker image. Those are out-of-scope for Charon to patch directly.
|
||||
|
||||
The most actionable findings are stale compiled Charon binaries built with go1.25.4–go1.25.6 that carry Critical/High stdlib CVEs and should be rebuilt with the current go1.26.1 toolchain.
|
||||
| Category | Critical | High | Medium | Low | Total |
|
||||
|------------------------|----------|------|--------|-----|-------|
|
||||
| **Active (unignored)** | 0 | 0 | 4 | 2 | 6 |
|
||||
| **Ignored (documented)**| 0 | 4 | 0 | 0 | 4 |
|
||||
| **Resolved since last audit** | 1 | 4 | 1 | 0 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Root Cause: Why These Packages Appear in Scans
|
||||
## Scans Executed
|
||||
|
||||
### Mechanism: go-module-binary-cataloger
|
||||
|
||||
When Syft generates the SBOM from the Docker image (not from source), it uses the **`go-module-binary-cataloger`** to read embedded Go build info from all compiled Go binaries in the image. Every Go binary compiled since Go 1.18 embeds a complete list of its upstream module dependencies via `debug/buildinfo`.
|
||||
|
||||
This means Syft finds packages from *any* Go binary on the image filesystem — including third-party tools like CrowdSec and Caddy — and reports them as if they were Charon dependencies.
|
||||
|
||||
### Confirmed Binary Sources
|
||||
|
||||
| Package | Version | Binary Path | Binary's Main Module |
|
||||
|---|---|---|---|
|
||||
| `github.com/buger/jsonparser` | v1.1.1 | `/usr/local/bin/crowdsec`, `/usr/local/bin/cscli` | `github.com/crowdsecurity/crowdsec` |
|
||||
| `github.com/jackc/pgproto3/v2` | v2.3.3 | `/usr/local/bin/crowdsec`, `/usr/local/bin/cscli` | `github.com/crowdsecurity/crowdsec` |
|
||||
| `github.com/russellhaering/goxmldsig` | v1.5.0 | `/usr/bin/caddy` | `caddy` |
|
||||
| `google.golang.org/grpc` | v1.74.2 | `/usr/local/bin/crowdsec`, `/usr/local/bin/cscli` | `github.com/crowdsecurity/crowdsec` |
|
||||
| `google.golang.org/grpc` | v1.79.1 | `/usr/bin/caddy` | `caddy` |
|
||||
|
||||
**Verification**: None of these packages appear in `backend/go.mod`, `backend/go.sum`, or the output of `go mod graph`.
|
||||
|
||||
### Why `grype dir:.` Flags Module Cache Artifacts
|
||||
|
||||
Running `grype dir:.` over the Charon workspace also scans `.cache/go/pkg/mod/` — the local Go module download cache. This directory contains the `go.mod` files of every transitively downloaded module. Grype reads those `go.mod` files and flags vulnerable version references within them, even though those versions are not compiled into the Charon binary. All module-cache findings have locations beginning with `/.cache/go/pkg/mod/` and are not exploitable in Charon.
|
||||
|
||||
### Stale SBOM: `sbom-generated.json`
|
||||
|
||||
`sbom-generated.json` (dated **2026-02-21**) was generated by an earlier workflow before the grpc bump and uses a format with no version or PURL data. Grype reading this file matches vulnerabilities against package names alone with no version filter, inflating findings. The authoritative SBOM is `sbom.cyclonedx.json` (dated **2026-03-18**, generated by Syft 1.42.2).
|
||||
| # | Scan | Tool | Result |
|
||||
|---|-------------------------------|-----------|----------------------|
|
||||
| 1 | Trivy Filesystem | Trivy | 0 findings (no lang-specific files detected) |
|
||||
| 2 | Docker Image (SBOM + Grype) | Syft/Grype| 6 active, 8 ignored |
|
||||
| 3 | Trivy Image Report | Trivy | 1 HIGH (stale Feb 25 report; resolved in current build) |
|
||||
| 4 | CodeQL Go | CodeQL | 1 finding (false positive — see below) |
|
||||
| 5 | CodeQL JavaScript | CodeQL | 0 findings |
|
||||
| 6 | GORM Security Scanner | Custom | PASSED (0 issues, 2 info) |
|
||||
| 7 | Lefthook / Pre-commit | Lefthook | Configured (project uses `lefthook.yml`, not `.pre-commit-config.yaml`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. CVE-by-CVE Status
|
||||
## Active Findings (Unignored)
|
||||
|
||||
### CVE-2026-33186 — `google.golang.org/grpc`
|
||||
### CVE-2025-60876 — BusyBox wget HTTP Request Smuggling
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Charon source (backend/go.mod)** | v1.79.3 — **PATCHED** ✓ |
|
||||
| **CrowdSec binary (`/usr/local/bin/crowdsec`)** | v1.74.2 — out of scope |
|
||||
| **Caddy binary (`/usr/bin/caddy`)** | v1.79.1 — out of scope |
|
||||
| **False positive for Charon?** | Partially — Charon's own code is patched. SBOM findings persist from Docker image binaries. |
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Severity** | Medium (CVSS 6.5) |
|
||||
| **Package** | `busybox` 1.37.0-r30 (Alpine APK) |
|
||||
| **Affected** | `busybox`, `busybox-binsh`, `busybox-extras`, `ssl_client` (4 matches) |
|
||||
| **Fix Available** | No |
|
||||
| **Classification** | AWAITING UPSTREAM |
|
||||
| **EPSS** | 0.00064 (0.20 percentile) |
|
||||
|
||||
**Remediation**: Upgrade the CrowdSec and Caddy Docker image versions. The fix in Charon's source is complete.
|
||||
**Description**: BusyBox wget through 1.37 accepts raw CR/LF and other C0 control bytes
|
||||
in the HTTP request-target, allowing request line splitting and header injection (CWE-284).
|
||||
|
||||
**Risk Assessment**: Low practical risk. Charon does not invoke `busybox wget` in its
|
||||
application logic. The vulnerable `wget` applet would need to be manually invoked inside
|
||||
the container with attacker-controlled URLs.
|
||||
|
||||
**Remediation**: Monitor Alpine 3.23 for a patched `busybox` APK. No action required
|
||||
until upstream ships a fix.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-479m-364c-43vc — `github.com/russellhaering/goxmldsig` v1.5.0
|
||||
### CVE-2026-26958 / GHSA-fw7p-63qq-7hpr — edwards25519 MultiScalarMult Invalid Results
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **In Charon go.mod / go.sum** | No |
|
||||
| **In go mod graph** | No |
|
||||
| **Source** | `/usr/bin/caddy` binary in Docker image |
|
||||
| **False positive for Charon?** | **Yes** |
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Severity** | Low (CVSS 1.7) |
|
||||
| **Package** | `filippo.io/edwards25519` v1.1.0 |
|
||||
| **Location** | CrowdSec binaries (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) |
|
||||
| **Fix Available** | v1.1.1 |
|
||||
| **Classification** | AWAITING UPSTREAM |
|
||||
| **EPSS** | 0.00018 (0.04 percentile) |
|
||||
|
||||
**Remediation**: Requires upgrading the Caddy Docker image tag. Track upstream Caddy release notes for a patched `goxmldsig` dependency.
|
||||
**Description**: `MultiScalarMult` produces invalid results or undefined behavior if
|
||||
the receiver is not the identity point. This is a rarely used, advanced API.
|
||||
|
||||
**Risk Assessment**: Minimal. CrowdSec does not directly expose edwards25519
|
||||
`MultiScalarMult` to external input. The fix exists at v1.1.1 but requires CrowdSec
|
||||
to rebuild with the updated dependency.
|
||||
|
||||
**Remediation**: Awaiting CrowdSec upstream release with updated dependency. No
|
||||
action available for Charon maintainers.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-6g7g-w4f8-9c9x — `github.com/buger/jsonparser` v1.1.1
|
||||
## Ignored Findings (Documented with Justification)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **In Charon go.mod / go.sum** | No |
|
||||
| **In go mod graph** | No |
|
||||
| **Source** | `/usr/local/bin/crowdsec` and `/usr/local/bin/cscli` in Docker image |
|
||||
| **False positive for Charon?** | **Yes** |
|
||||
These findings are suppressed in the Grype configuration with documented risk
|
||||
acceptance rationale. All are in third-party binaries bundled in the container;
|
||||
none are in Charon's own code.
|
||||
|
||||
**Remediation**: Requires upgrading the CrowdSec Docker image tag.
|
||||
### CVE-2026-2673 — OpenSSL TLS 1.3 Key Exchange Group Downgrade
|
||||
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Severity** | High (CVSS 7.5) |
|
||||
| **Package** | `libcrypto3` / `libssl3` 3.5.5-r0 |
|
||||
| **Matches** | 2 (libcrypto3, libssl3) |
|
||||
| **Classification** | ALREADY DOCUMENTED · AWAITING UPSTREAM |
|
||||
|
||||
Charon terminates TLS at the Caddy layer; the Go backend does not act as a raw
|
||||
TLS 1.3 server. Alpine 3.23 still ships 3.5.5-r0. Risk accepted pending Alpine patch.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-jqcq-xjh3-6g23 — `github.com/jackc/pgproto3/v2` v2.3.3
|
||||
### GHSA-6g7g-w4f8-9c9x — DoS in buger/jsonparser (CrowdSec)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **In Charon go.mod / go.sum** | No |
|
||||
| **In go mod graph** | No |
|
||||
| **Source** | `/usr/local/bin/crowdsec` and `/usr/local/bin/cscli` in Docker image |
|
||||
| **False positive for Charon?** | **Yes** |
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Severity** | High (CVSS 7.5) |
|
||||
| **Package** | `github.com/buger/jsonparser` v1.1.1 |
|
||||
| **Matches** | 2 (crowdsec, cscli binaries) |
|
||||
| **Fix Available** | v1.1.2 |
|
||||
| **Classification** | ALREADY DOCUMENTED · AWAITING UPSTREAM |
|
||||
|
||||
**Remediation**: Requires upgrading the CrowdSec Docker image tag.
|
||||
Charon does not use this package directly. The vector requires reaching CrowdSec's
|
||||
internal JSON processing pipeline. Risk accepted pending CrowdSec upstream fix.
|
||||
|
||||
---
|
||||
|
||||
## 3. Actionable Findings
|
||||
### GHSA-jqcq-xjh3-6g23 / GHSA-x6gf-mpr2-68h6 / CVE-2026-4427 — DoS in pgproto3/v2 (CrowdSec)
|
||||
|
||||
### 3.1 Stdlib CVEs in Stale Charon Binaries (Critical/High)
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Severity** | High (CVSS 7.5) |
|
||||
| **Package** | `github.com/jackc/pgproto3/v2` v2.3.3 |
|
||||
| **Matches** | 4 (2 GHSAs × 2 binaries) |
|
||||
| **Fix Available** | No (v2 is archived/EOL) |
|
||||
| **Classification** | ALREADY DOCUMENTED · AWAITING UPSTREAM |
|
||||
|
||||
Grype found Charon binaries on-disk compiled with old Go versions. The current toolchain is **go1.26.1**, which patches all of the following.
|
||||
|
||||
| Binary | Go Version | Notable CVEs |
|
||||
|---|---|---|
|
||||
| `.trivy_logs/charon_binary` | go1.25.4 (Nov 2025 artifact) | CVE-2025-68121 (Critical), CVE-2025-61726/29/31/32 (High) |
|
||||
| `backend/bin/charon`, `backend/bin/api`, `backend/bin/charon-debug` | go1.25.6 | CVE-2025-68121 (Critical), CVE-2025-61732 (High), CVE-2026-25679 (High) |
|
||||
| `backend/api` (root-level) | go1.25.7 | CVE-2026-25679 (High), CVE-2026-27142 (Medium) |
|
||||
|
||||
**CVE-2025-68121** (Critical, Go stdlib) is the single highest-severity finding in this report.
|
||||
|
||||
**Remediation**: Rebuild all binaries with go1.26.1. Delete `.trivy_logs/charon_binary` (stale Nov 2025 artifact) or add `.trivy_logs/` to `.gitignore`.
|
||||
pgproto3/v2 is archived with no fix planned. CrowdSec must migrate to pgx/v5.
|
||||
Charon uses SQLite, not PostgreSQL; this code path is unreachable in standard
|
||||
deployment.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Python Virtual Environment Packages (Dev Tooling Only)
|
||||
## Resolved Findings (Since Last SECURITY.md Update)
|
||||
|
||||
Local `.venv` directories contain outdated packages. These are not shipped in the Docker image.
|
||||
The following vulnerabilities documented in SECURITY.md are no longer detected in the
|
||||
current image build. **SECURITY.md should be updated to move these to "Patched
|
||||
Vulnerabilities".**
|
||||
|
||||
| Severity | ID | Package | Fix |
|
||||
|---|---|---|---|
|
||||
| High | GHSA-8rrh-rw8j-w5fx | wheel 0.45.1 | `pip install --upgrade wheel` |
|
||||
| High | GHSA-58pv-8j8x-9vj2 | jaraco-context 5.3.0 | `pip install --upgrade setuptools` |
|
||||
| Medium | GHSA-597g-3phw-6986 | virtualenv 20.35.4 | `pip install --upgrade virtualenv` |
|
||||
| Medium | GHSA-qmgc-5h2g-mvrw / GHSA-w853-jp5j-5j7f | filelock 3.20.0 | `pip install --upgrade filelock` |
|
||||
| Low | GHSA-6vgw-5pg2-w6jp | pip 24.0 / 25.3 | `pip install --upgrade pip` |
|
||||
### CVE-2025-68121 — Go Stdlib Critical in CrowdSec (RESOLVED)
|
||||
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Previous Severity** | Critical |
|
||||
| **Resolution** | CrowdSec binaries now compiled with Go 1.26.1 (was Go 1.25.6) |
|
||||
| **Verified** | Not detected in Grype scan of current image |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Module Cache False Positives (All Confirmed Non-Exploitable)
|
||||
### CHARON-2025-001 — CrowdSec Go Stdlib CVE Cluster (RESOLVED)
|
||||
|
||||
Flagged solely because they appear in `go.mod` files inside `.cache/go/pkg/mod/`, not in any compiled Charon binary:
|
||||
|
||||
| ID | Package | Flagged Version | Cache Source | Actual Charon Version |
|
||||
|---|---|---|---|---|
|
||||
| GHSA-p77j-4mvh-x3m3 (Critical) | google.golang.org/grpc | v1.67.0 | `containerd/errdefs/go.mod` | v1.79.3 |
|
||||
| GHSA-9h8m-3fm2-qjrq (High) | go.opentelemetry.io/otel/sdk | v1.38.0 | `otelhttp@v0.63.0/go.mod` | v1.42.0 |
|
||||
| GHSA-47m2-4cr7-mhcw (High) | github.com/quic-go/quic-go | v0.54.0 | `gin-gonic/gin@v1.11.0/go.mod` | not a direct dep |
|
||||
| GHSA-hcg3-q754-cr77 (High) | golang.org/x/crypto | v0.26.0 | `quic-go@v0.54.1/go.mod` | v0.46.0 |
|
||||
| GHSA-cxww-7g56-2vh6 (High) | actions/download-artifact | v4 | `docker/docker` GH workflows in cache | N/A |
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Previous Severity** | High |
|
||||
| **Aliases** | CVE-2025-58183, CVE-2025-58186, CVE-2025-58187, CVE-2025-61729, CVE-2026-25679, CVE-2025-61732, CVE-2026-27142, CVE-2026-27139 |
|
||||
| **Resolution** | CrowdSec binaries now compiled with Go 1.26.1 |
|
||||
| **Verified** | None of the aliased CVEs detected in Grype scan |
|
||||
|
||||
---
|
||||
|
||||
## 4. Scan Configuration Recommendations
|
||||
### CVE-2026-27171 — zlib CPU Exhaustion (RESOLVED)
|
||||
|
||||
### Exclude Go Module Cache from `grype dir:.`
|
||||
|
||||
Create `.grype.yaml` at project root:
|
||||
|
||||
```yaml
|
||||
ignore:
|
||||
- package:
|
||||
location: "**/.cache/**"
|
||||
- package:
|
||||
location: "**/node_modules/**"
|
||||
```
|
||||
|
||||
Alternatively, scan the SBOM directly rather than the filesystem: `grype sbom:sbom.cyclonedx.json`.
|
||||
|
||||
### Regenerate or Remove `sbom-generated.json`
|
||||
|
||||
`sbom-generated.json` (Feb 21 2026) contains packages with no version or PURL data, causing name-only vulnerability matching. Delete it or regenerate with: `syft scan dir:. -o cyclonedx-json > sbom-generated.json`.
|
||||
|
||||
### Delete or Gitignore `.trivy_logs/charon_binary`
|
||||
|
||||
The 23MB stale binary `.trivy_logs/charon_binary` (go1.25.4, Nov 2025) is a Trivy scan artifact causing several Critical/High CVE findings. Add `.trivy_logs/*.binary` or the whole `.trivy_logs/` directory to `.gitignore`.
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Previous Severity** | Medium |
|
||||
| **Resolution** | Alpine now ships `zlib` 1.3.2-r0 (fix threshold: 1.3.2) |
|
||||
| **Verified** | Not detected in Grype scan; zlib 1.3.2-r0 confirmed in SBOM |
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
### CVE-2026-33186 — gRPC-Go Authorization Bypass (RESOLVED)
|
||||
|
||||
| # | Finding | Severity | False Positive? | Action Required |
|
||||
|---|---|---|---|---|
|
||||
| 1 | CVE-2025-68121 in `.trivy_logs/charon_binary` + `backend/bin/*` | **Critical** | No | Rebuild binaries with go1.26.1; delete stale `.trivy_logs/charon_binary` |
|
||||
| 2 | CVE-2026-33186 in Charon source | — | N/A | **Already fixed** (v1.79.3) |
|
||||
| 3 | CVE-2026-33186 in CrowdSec/Caddy binaries | High | Yes (for Charon) | Upgrade CrowdSec and Caddy Docker image tags |
|
||||
| 4 | GHSA-479m-364c-43vc (`goxmldsig`) | Medium | **Yes** | Upgrade Caddy Docker image |
|
||||
| 5 | GHSA-6g7g-w4f8-9c9x (`jsonparser`) | Medium | **Yes** | Upgrade CrowdSec Docker image |
|
||||
| 6 | GHSA-jqcq-xjh3-6g23 (`pgproto3/v2`) | Medium | **Yes** | Upgrade CrowdSec Docker image |
|
||||
| 7 | High stdlib CVEs in `backend/bin/` binaries | High | No | Rebuild with go1.26.1 |
|
||||
| 8 | Python venv packages | Medium | No (dev only) | `pip upgrade` in local envs |
|
||||
| 9 | Module cache false positives | Critical–High | **Yes** | Exclude `.cache/` from `grype dir:.` |
|
||||
| 10 | Stale `sbom-generated.json` | — | Yes | Delete or regenerate |
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Previous Severity** | Critical |
|
||||
| **Packages** | `google.golang.org/grpc` v1.74.2 (CrowdSec), v1.79.1 (Caddy) |
|
||||
| **Resolution** | Upstream releases now include patched gRPC (>= v1.79.3) |
|
||||
| **Verified** | Not detected in Grype scan; ignore rule present but no match |
|
||||
|
||||
---
|
||||
|
||||
### GHSA-69x3-g4r3-p962 / CVE-2026-25793 — Nebula ECDSA Malleability (RESOLVED)
|
||||
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Previous Severity** | High |
|
||||
| **Package** | `github.com/slackhq/nebula` v1.9.7 in Caddy |
|
||||
| **Resolution** | Caddy now ships with nebula >= v1.10.3 |
|
||||
| **Verified** | Not detected in Grype scan; Trivy image report from Feb 25 had this but current build does not |
|
||||
|
||||
> **Note**: The stale Trivy image report (`trivy-image-report.json`, dated 2026-02-25) still
|
||||
> shows CVE-2026-25793. This report predates the current build and should be regenerated.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-479m-364c-43vc — goxmldsig XML Signature Bypass (RESOLVED)
|
||||
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Previous Severity** | High |
|
||||
| **Package** | `github.com/russellhaering/goxmldsig` v1.5.0 in Caddy |
|
||||
| **Resolution** | Caddy now ships with goxmldsig >= v1.6.0 |
|
||||
| **Verified** | Not detected in Grype scan; ignore rule present but no match |
|
||||
|
||||
---
|
||||
|
||||
## CodeQL Analysis
|
||||
|
||||
### go/cookie-secure-not-set — FALSE POSITIVE
|
||||
|
||||
| Field | Value |
|
||||
|------------------|-------|
|
||||
| **Severity** | Medium (CodeQL) |
|
||||
| **File** | `backend/internal/api/handlers/auth_handler.go:152` |
|
||||
| **Classification** | FALSE POSITIVE (stale SARIF) |
|
||||
|
||||
**Finding**: CodeQL reports "Cookie does not set Secure attribute to true" at line 152.
|
||||
|
||||
**Verification**: The `setSecureCookie` function at line 148-156 calls `c.SetCookie()`
|
||||
with `secure: true` (6th positional argument). The Secure attribute IS set correctly.
|
||||
This SARIF was generated from a previous code version and does not reflect the current
|
||||
source. **The CodeQL SARIF files should be regenerated.**
|
||||
|
||||
### JavaScript / JS
|
||||
|
||||
No findings. Both `codeql-results-javascript.sarif` and `codeql-results-js.sarif` contain
|
||||
0 results.
|
||||
|
||||
---
|
||||
|
||||
## GORM Security Scanner
|
||||
|
||||
| Metric | Value |
|
||||
|------------|-------|
|
||||
| **Result** | PASSED |
|
||||
| **Files** | 43 Go files (2,396 lines) |
|
||||
| **Critical** | 0 |
|
||||
| **High** | 0 |
|
||||
| **Medium** | 0 |
|
||||
| **Info** | 2 (missing indexes on foreign keys in `UserPermittedHost`) |
|
||||
|
||||
The 2 informational suggestions (`UserID` and `ProxyHostID` missing `gorm:"index"` in
|
||||
`backend/internal/models/user.go:130-131`) are performance recommendations, not security
|
||||
issues. They do not block this audit.
|
||||
|
||||
---
|
||||
|
||||
## CI vs Local Scan Discrepancy
|
||||
|
||||
The CI reported **3 Critical, 5 High, 1 Medium**. The local scan on the freshly built
|
||||
image reports **0 Critical, 0 High, 4 Medium, 2 Low** (active) plus **4 High** (ignored).
|
||||
|
||||
**Root causes for the discrepancy:**
|
||||
|
||||
1. **Resolved vulnerabilities**: 3 Critical and 4 High findings were resolved by Go 1.26.1
|
||||
compilation and upstream Caddy/CrowdSec dependency updates since the CI image was built.
|
||||
2. **Grype ignore rules**: The local scan applies documented risk acceptance rules that
|
||||
suppress 4 High findings in third-party binaries. CI (Trivy) does not use these rules.
|
||||
3. **Stale CI artifacts**: The `trivy-image-report.json` dates from 2026-02-25 and does
|
||||
not reflect the current image state. The `codeql-results-go.sarif` references code that
|
||||
has since been fixed.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### Immediate (This Sprint)
|
||||
|
||||
1. **Update SECURITY.md**: Move CVE-2025-68121, CHARON-2025-001, and CVE-2026-27171 to
|
||||
a "Patched Vulnerabilities" section. Add CVE-2025-60876 and CVE-2026-26958 as new
|
||||
known vulnerabilities.
|
||||
|
||||
2. **Regenerate stale scan artifacts**: Re-run Trivy image scan and CodeQL analysis to
|
||||
produce current SARIF/JSON files. The existing files predate fixes and produce
|
||||
misleading CI results.
|
||||
|
||||
3. **Clean up Grype ignore rules**: Remove ignore entries for vulnerabilities that are
|
||||
no longer detected (CVE-2026-33186, GHSA-69x3-g4r3-p962, GHSA-479m-364c-43vc).
|
||||
Stale ignore rules obscure the actual security posture.
|
||||
|
||||
### Next Release
|
||||
|
||||
4. **Monitor Alpine APK updates**: Watch for patched `busybox` (CVE-2025-60876) and
|
||||
`openssl` (CVE-2026-2673) packages in Alpine 3.23.
|
||||
|
||||
5. **Monitor CrowdSec releases**: Watch for CrowdSec builds with updated
|
||||
`filippo.io/edwards25519` >= v1.1.1, `buger/jsonparser` >= v1.1.2, and
|
||||
`pgx/v5` migration (replacing pgproto3/v2).
|
||||
|
||||
6. **Monitor Go 1.26.2-alpine**: When available, bump `GO_VERSION` to pick up any
|
||||
remaining stdlib patches.
|
||||
|
||||
### Informational (Non-Blocking)
|
||||
|
||||
7. **GORM indexes**: Consider adding `gorm:"index"` to `UserID` and `ProxyHostID` in
|
||||
`UserPermittedHost` for query performance.
|
||||
|
||||
---
|
||||
|
||||
## Gotify Token Review
|
||||
|
||||
Verified: No Gotify application tokens appear in scan output, log artifacts, test results,
|
||||
API examples, or URL query parameters. All diagnostic output is clean.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Charon container image security posture has materially improved. Six previously known
|
||||
vulnerabilities are now resolved through Go toolchain and dependency updates. The remaining
|
||||
active findings are medium/low severity, reside in Alpine base packages and CrowdSec
|
||||
third-party binaries, and have no available fixes. No vulnerabilities exist in Charon's
|
||||
own application code. GORM and CodeQL scans confirm the backend code is clean.
|
||||
|
||||
609
docs/reports/qa_report_2026-03-21_cwe614.md
Normal file
609
docs/reports/qa_report_2026-03-21_cwe614.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# QA Security Audit Report — CWE-614 Remediation
|
||||
|
||||
**Date:** 2026-03-21
|
||||
**Scope:** `backend/internal/api/handlers/auth_handler.go` — removal of `secure = false` branch from `setSecureCookie`
|
||||
**Audited by:** QA Security Agent
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Backend-only change. File audited:
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `backend/internal/api/handlers/auth_handler.go` | Modified — `secure = false` branch removed; `Secure` always `true` |
|
||||
| `backend/internal/api/handlers/auth_handler_test.go` | Modified — all `TestSetSecureCookie_*` assertions updated to `assert.True(t, cookie.Secure)` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Test Results
|
||||
|
||||
| Metric | Value | Gate | Status |
|
||||
|---|---|---|---|
|
||||
| Statement coverage | 88.0% | ≥ 87% | ✅ PASS |
|
||||
| Line coverage | 88.2% | ≥ 87% | ✅ PASS |
|
||||
| Test failures | 0 | 0 | ✅ PASS |
|
||||
|
||||
All `TestSetSecureCookie_*` variants assert `cookie.Secure == true` unconditionally, correctly reflecting the remediated behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 2. Lint Results
|
||||
|
||||
**Tool:** `golangci-lint` (fast config — staticcheck, govet, errcheck, ineffassign, unused)
|
||||
|
||||
**Result:** `0 issues` — ✅ PASS
|
||||
|
||||
---
|
||||
|
||||
## 3. Pre-commit Hooks
|
||||
|
||||
**Tool:** Lefthook v2.1.4
|
||||
|
||||
| Hook | Result |
|
||||
|---|---|
|
||||
| check-yaml | ✅ PASS |
|
||||
| actionlint | ✅ PASS |
|
||||
| end-of-file-fixer | ✅ PASS |
|
||||
| trailing-whitespace | ✅ PASS |
|
||||
| dockerfile-check | ✅ PASS |
|
||||
| shellcheck | ✅ PASS |
|
||||
|
||||
Go-specific hooks (`go-vet`, `golangci-lint-fast`) were skipped — no staged files. These were validated directly via `make lint-fast`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Trivy Security Scan
|
||||
|
||||
**Tool:** Trivy v0.52.2
|
||||
|
||||
### New Vulnerabilities Introduced by This Change
|
||||
|
||||
**None.** Zero HIGH or CRITICAL vulnerabilities attributable to the CWE-614 remediation.
|
||||
|
||||
### Pre-existing Baseline Finding (unrelated)
|
||||
|
||||
| ID | Severity | Type | Description |
|
||||
|---|---|---|---|
|
||||
| DS002 | HIGH | Dockerfile misconfiguration | Container runs as root — pre-existing, not introduced by this change |
|
||||
|
||||
---
|
||||
|
||||
## 5. CWE-614 Verification
|
||||
|
||||
### Pattern Search: `secure = false` in handlers package
|
||||
|
||||
```
|
||||
grep -rn "secure = false" /projects/Charon/backend/
|
||||
```
|
||||
|
||||
**Result:** 0 matches — ✅ CLEARED
|
||||
|
||||
### Pattern Search: Inline CodeQL suppression
|
||||
|
||||
```
|
||||
grep -rn "codeql[go/cookie-secure-not-set]" /projects/Charon/backend/
|
||||
```
|
||||
|
||||
**Result:** 0 matches — ✅ CLEARED
|
||||
|
||||
### `setSecureCookie` Implementation
|
||||
|
||||
The function unconditionally passes `true` as the `secure` argument to `c.SetCookie`:
|
||||
|
||||
```go
|
||||
c.SetCookie(
|
||||
name, // name
|
||||
value, // value
|
||||
maxAge, // maxAge in seconds
|
||||
"/", // path
|
||||
domain, // domain (empty = current host)
|
||||
true, // secure ← always true, no conditional branch
|
||||
true, // httpOnly
|
||||
)
|
||||
```
|
||||
|
||||
All test cases (`TestSetSecureCookie_HTTPS_Strict`, `_HTTP_Lax`, `_HTTP_Loopback_Insecure`,
|
||||
`_ForwardedHTTPS_*`, `_HTTP_PrivateIP_Insecure`, `_HTTP_10Network_Insecure`,
|
||||
`_HTTP_172Network_Insecure`) assert `cookie.Secure == true`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---|---|
|
||||
| Backend unit tests | ✅ PASS | 0 failures, 88.0% coverage (gate: 87%) |
|
||||
| Lint | ✅ PASS | 0 issues |
|
||||
| Pre-commit hooks | ✅ PASS | All 6 active hooks passed |
|
||||
| Trivy | ✅ PASS | No new HIGH/CRITICAL vulns |
|
||||
| `secure = false` removed | ✅ CLEARED | 0 matches in handlers package |
|
||||
| CodeQL suppression removed | ✅ CLEARED | 0 matches in handlers package |
|
||||
|
||||
---
|
||||
|
||||
## Overall: ✅ PASS
|
||||
|
||||
The CWE-614 remediation is complete and correct. All cookies set by `setSecureCookie` now unconditionally carry `Secure = true`. No regressions, no new security findings, and coverage remains above the required threshold.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- Previous reports archived below -->
|
||||
|
||||
# QA Audit Report — PR-1: Allow Empty Value in UpdateSetting
|
||||
|
||||
**Date:** 2026-03-17
|
||||
**Scope:** Remove `binding:"required"` from `Value` field in `UpdateSettingRequest`
|
||||
**File:** `backend/internal/api/handlers/settings_handler.go`
|
||||
|
||||
---
|
||||
|
||||
# QA Security Audit Report — Rate Limit CI Fix
|
||||
|
||||
**Audited by**: QA Security Auditor
|
||||
**Date**: 2026-03-17
|
||||
**Spec reference**: `docs/plans/rate_limit_ci_fix_spec.md`
|
||||
**Files audited**:
|
||||
- `scripts/rate_limit_integration.sh`
|
||||
- `Dockerfile` (GeoIP section, non-CI path)
|
||||
- `.github/workflows/rate-limit-integration.yml`
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Check Results
|
||||
|
||||
| Check | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| Bash syntax | `bash -n scripts/rate_limit_integration.sh` | ✅ PASS (exit 0) |
|
||||
| Pre-commit hooks | `lefthook run pre-commit` (project uses lefthook; no `.pre-commit-config.yaml`) | ✅ PASS — all 6 hooks passed: `check-yaml`, `actionlint`, `end-of-file-fixer`, `trailing-whitespace`, `dockerfile-check`, `shellcheck` |
|
||||
| Caddy admin API trailing slash (workflow) | `grep -n "2119" .github/workflows/rate-limit-integration.yml` | ✅ PASS — line 71 references `/config/` (trailing slash present) |
|
||||
| Caddy admin API trailing slash (script) | All 6 occurrences of `localhost:2119/config` in script | ✅ PASS — all use `/config/` |
|
||||
|
||||
---
|
||||
|
||||
## Security Focus Area Results
|
||||
|
||||
### 1. Credential Handling — `TMP_COOKIE`
|
||||
|
||||
**`mktemp` usage**: `TMP_COOKIE=$(mktemp)` at line 208. Creates a file in `/tmp` with `600` permissions via the OS. ✅ SECURE.
|
||||
|
||||
**Removal on exit**: The `cleanup()` function at line 103 removes the file with `rm -f "${TMP_COOKIE:-}"`. However, `cleanup` is only registered via explicit calls — there is **no `trap cleanup EXIT`**. Only `trap on_failure ERR` is registered (line 108).
|
||||
|
||||
**Gap**: On 5 early `exit 1` paths after line 208 (login failure L220, auth failure L251, Caddy readiness failure L282, security config failure L299, and handler verification failure L316), `cleanup` is never called. The cookie file is left in `/tmp`.
|
||||
|
||||
**Severity**: LOW — The cookie contains session credentials for a localhost test server (`ratelimit@example.local` / `password123`, non-production). CI runners are ephemeral and auto-cleaned. Local runs will leave a `/tmp/tmp.XXXXXX` file until next reboot or manual cleanup.
|
||||
|
||||
**Note**: The exit at line 386 (inside the 429 enforcement failure block) intentionally skips cleanup to leave containers running for manual inspection. This is by design and acceptable.
|
||||
|
||||
**Recommendation**: Add `trap cleanup EXIT` immediately after `trap on_failure ERR` (line 109) to ensure the cookie file is always removed.
|
||||
|
||||
---
|
||||
|
||||
### 2. `curl` — Sensitive Values in Command-Line Arguments
|
||||
|
||||
Cookie file path is passed via `-c ${TMP_COOKIE}` and `-b ${TMP_COOKIE}` (unquoted). No credentials, tokens, or API keys are passed as command-line arguments. All authentication is via the cookie file (read/write by path), which is the correct pattern — cookie values never appear in `ps` output.
|
||||
|
||||
**Finding (LOW)**: `${TMP_COOKIE}` is unquoted in all 6 curl invocations. `mktemp` on Linux produces paths of the form `/tmp/tmp.XXXXXX` which never contain spaces or shell metacharacters under default `$TMPDIR`. However, under a non-standard `$TMPDIR` (e.g., `/tmp/my dir/`) this would break. This is a portability issue, not a security issue.
|
||||
|
||||
**Recommendation**: Quote `"${TMP_COOKIE}"` in all curl invocations.
|
||||
|
||||
---
|
||||
|
||||
### 3. Shell Injection
|
||||
|
||||
All interpolated values in curl `-d` payloads are either:
|
||||
- Script-level constants (`RATE_LIMIT_REQUESTS=3`, `RATE_LIMIT_WINDOW_SEC=10`, `RATE_LIMIT_BURST=1`, `TEST_DOMAIN=ratelimit.local`, `BACKEND_CONTAINER=ratelimit-backend`)
|
||||
- Values derived from API responses stored in double-quoted variables (`"$CREATE_RESP"`, `"$SEC_CONFIG_RESP"`)
|
||||
|
||||
No shell injection vector exists. All heredoc expansions (`cat <<EOF...EOF`) expand only the hardcoded constants listed above.
|
||||
|
||||
The UUID extraction pattern at line 429 includes `${TEST_DOMAIN}` unquoted within a `grep -o` pattern, but because the variable expands to `ratelimit.local` (controlled constant), this has no injection risk. The `.` in `ratelimit.local` is treated as a regex wildcard but in this context only matches the intended hostname. ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
### 4. `set -euo pipefail` Compatibility
|
||||
|
||||
The new status-capture idiom:
|
||||
|
||||
```bash
|
||||
LOGIN_STATUS=$(curl -s -w "\n%{http_code}" ... | tail -n1)
|
||||
```
|
||||
|
||||
Behavior under `set -euo pipefail`:
|
||||
- **Network failure** (curl exits non-zero, e.g., `ECONNREFUSED`): `pipefail` propagates curl's non-zero exit through the pipeline; the assignment fails; `set -e` fires the `on_failure` ERR trap and exits. ✅ Correct.
|
||||
- **HTTP error** (curl exits 0, HTTP 4xx/5xx): curl outputs `\n{code}`; `tail -n1` extracts the code; assignment succeeds; subsequent `[ "$LOGIN_STATUS" != "200" ]` detects the failure. ✅ Correct.
|
||||
- **Empty body edge case**: If curl returns an empty body, output is `\n200`. `tail -n1` → `200`; `head -n-1` → empty string. Status check still works. ✅ Correct.
|
||||
|
||||
The `SEC_CONFIG_RESP` split pattern (`tail -n1` for status, `head -n-1` for body) is correct for both single-line and multiline JSON responses. ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
### 5. Workflow Secrets Exposure
|
||||
|
||||
The workflow (`rate-limit-integration.yml`) contains **no `${{ secrets.* }}` references**. All test credentials are hardcoded constants in the script (`ratelimit@example.local` / `password123`), appropriate for an ephemeral test user that is registered and used only within the test run.
|
||||
|
||||
`$GITHUB_STEP_SUMMARY` output includes: container status, API config JSON, container logs. None of these contain secrets or credentials. The security config JSON may contain rate limit settings (integers) but nothing sensitive.
|
||||
|
||||
No accidental log exposure identified. ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
### 6. GeoIP Change — Supply-Chain Risk
|
||||
|
||||
**Change**: The non-CI Dockerfile build path previously ran `sha256sum -c -` against `GEOLITE2_COUNTRY_SHA256`. This was removed. The remaining guard is `[ -s /app/data/geoip/GeoLite2-Country.mmdb ]` (file-size non-empty check).
|
||||
|
||||
**Risk assessment** (MEDIUM): The download source is `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb`, a public GitHub repository. If this repository is compromised or the file is replaced with a malicious binary:
|
||||
- The `-s` check only verifies the file is non-empty
|
||||
- The application loads it at `CHARON_GEOIP_DB_PATH` for IP geolocation — a non-privileged read operation
|
||||
- A malicious file would not achieve RCE via MMDb parsing in the MaxMind reader library (no known attack surface), but could corrupt GeoIP lookups silently
|
||||
|
||||
**This is an acknowledged, pre-existing architectural limitation** documented in the spec. The `sha256sum` check was ineffective by design because the P3TERX repository updates the file continuously while the pinned hash only updates weekly via `update-geolite2.yml`. The new behavior (accept any non-empty file) is more honest about the actual constraint.
|
||||
|
||||
**Spec compliance**: `ARG GEOLITE2_COUNTRY_SHA256` is **retained** in the Dockerfile (line ~441) as required by the spec, preserving `update-geolite2.yml` workflow compatibility. ✅ PASS.
|
||||
|
||||
**Residual risk**: MEDIUM. Mitigated by: (1) `wget` uses HTTPS to fetch from GitHub (TLS in transit), (2) downstream Trivy scans of the built image would flag a malicious MMDB independently, (3) the GeoIP reader is sandboxed to a read operation with no known parse-exploit surface.
|
||||
|
||||
---
|
||||
|
||||
## Correctness Against Spec
|
||||
|
||||
| Spec Change | Implemented | Verified |
|
||||
|-------------|-------------|----------|
|
||||
| C1: Login status check (Step 4) | ✅ Yes — `LOGIN_STATUS` checked, fails fast on non-200 | Script lines 211–220 |
|
||||
| C2: Proxy host creation — auth failures fatal, 409 continues | ✅ Yes — 401/403 abort, other non-201 continues | Script lines 248–256 |
|
||||
| C3: Caddy admin API readiness gate before security config POST | ✅ Yes — 20-retry loop before SEC_CFG call | Script lines 274–284 |
|
||||
| C4: Security config POST status checked | ✅ Yes — `SEC_CONFIG_STATUS` checked, body logged on error | Script lines 286–301 |
|
||||
| C5: `verify_rate_limit_config` failure is hard exit | ✅ Yes — prints debug and `exit 1` | Script lines 307–318 |
|
||||
| C6: Pre-verification sleep increased 5 → 8 s | ✅ Yes — `sleep 8` | Script line 305 |
|
||||
| C7: Trailing slash on `/config/` | ✅ Yes — all 6 script occurrences; workflow line 71 | Confirmed by grep |
|
||||
| Dockerfile: sha256sum removed from non-CI path | ✅ Yes — only `-s` check remains | Dockerfile lines ~453–463 |
|
||||
| Dockerfile: `ARG GEOLITE2_COUNTRY_SHA256` retained | ✅ Yes — line ~441 | Dockerfile audited |
|
||||
| Workflow: debug dump uses `/config/` | ✅ Yes — line 71 | Confirmed by grep |
|
||||
|
||||
---
|
||||
|
||||
## Findings Summary
|
||||
|
||||
| ID | Severity | Area | Description |
|
||||
|----|----------|------|-------------|
|
||||
| M1 | MEDIUM | Dockerfile supply-chain | GeoIP downloaded without hash; `-s` is minimum viability only. Accepted trade-off per spec — hash was perpetually stale. |
|
||||
| L1 | LOW | Shell security | `${TMP_COOKIE}` unquoted in 6 curl invocations. No practical impact under standard `$TMPDIR`. |
|
||||
| L2 | LOW | Temp file hygiene | No `trap cleanup EXIT`; TMP_COOKIE and containers not cleaned on 5 early failure paths (lines 220, 251, 282, 299, 316). Low sensitivity (localhost test credentials only). |
|
||||
|
||||
No CRITICAL or HIGH severity findings.
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
**✅ APPROVED**
|
||||
|
||||
All spec-required changes are correctly implemented. No OWASP Top 10 vulnerabilities were introduced. The two LOW findings (unquoted variable, missing EXIT trap) are hygiene improvements that do not block the fix. The MEDIUM GeoIP supply-chain concern is a pre-existing architectural trade-off explicitly acknowledged in the spec.
|
||||
|
||||
### Recommended follow-up (non-blocking)
|
||||
|
||||
Add `trap cleanup EXIT` immediately after `trap on_failure ERR` in `scripts/rate_limit_integration.sh` to ensure TMP_COOKIE is always removed and containers are cleaned on all exit paths.
|
||||
**Purpose:** Allow admins to set a setting to an empty string value (required to fix the fresh-install CrowdSec enabling bug where `value` was legitimately empty).
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict: APPROVED
|
||||
|
||||
All structural, linting, and security gates pass. The change is correctly scoped to the build-only `frontend-builder` stage and introduces no new attack surface in the final runtime image.
|
||||
|
||||
---
|
||||
|
||||
## Changes Under Review
|
||||
|
||||
| Element | Location | Description |
|
||||
|---|---|---|
|
||||
| `ARG NPM_VERSION=11.11.1` | Line 30 (global ARG block) | Pinned npm version with Renovate comment |
|
||||
| `ARG NPM_VERSION` | Line 105 (frontend-builder) | Bare re-declaration to inherit global ARG into stage |
|
||||
| `# hadolint ignore=DL3017` | Line 106 | Lint suppression for intentional `apk upgrade` |
|
||||
| `RUN apk upgrade --no-cache && ...` | Lines 107–109 | Three-command RUN: OS patch + npm upgrade + cache clear |
|
||||
| `RUN npm ci` | Line 111 | Unchanged dependency install follows the new RUN block |
|
||||
|
||||
---
|
||||
|
||||
## Gate Summary
|
||||
|
||||
| # | Gate | Result | Details |
|
||||
|---|---|---|---|
|
||||
| 1 | Global `ARG NPM_VERSION` present with Renovate comment | **PASS** | Line 30; `# renovate: datasource=npm depName=npm` at line 29 |
|
||||
| 2 | `ARG NPM_VERSION` bare re-declaration inside stage | **PASS** | Line 105 |
|
||||
| 3 | `# hadolint ignore=DL3017` on own line before RUN block | **PASS** | Line 106 |
|
||||
| 4 | RUN block — three correct commands | **PASS** | Lines 107–109: `apk upgrade --no-cache`, `npm install -g npm@${NPM_VERSION} --no-fund --no-audit`, `npm cache clean --force` |
|
||||
| 5 | `RUN npm ci` still present and follows new block | **PASS** | Line 111 |
|
||||
| 6 | FROM line unchanged | **PASS** | `node:24.14.0-alpine@sha256:7fddd9ddeae8196abf4a3ef2de34e11f7b1a722119f91f28ddf1e99dcafdf114` |
|
||||
| 7 | `${NPM_VERSION}` used (no hard-coded version) | **PASS** | Confirmed variable reference in install command |
|
||||
| 8 | Trivy config scan (HIGH/CRITICAL) | **PASS** | 0 misconfigurations |
|
||||
| 9 | Hadolint (new code area) | **PASS** | No errors or warnings; only pre-existing `info`-level DL3059 at unrelated lines |
|
||||
| 10 | Runtime image isolation | **PASS** | Only `/app/frontend/dist` artifacts copied into final image via line 535 |
|
||||
| 11 | `--no-audit` acceptability | **PASS** | Applies only to the single-package global npm upgrade; `npm ci` is unaffected |
|
||||
| 12 | `npm cache clean --force` safety | **PASS** | Safe cache clear between npm tool upgrade and dependency install |
|
||||
|
||||
---
|
||||
|
||||
## 1. Dockerfile Structural Verification
|
||||
|
||||
### Global ARG block (lines 25–40)
|
||||
|
||||
```
|
||||
29: # renovate: datasource=npm depName=npm
|
||||
30: ARG NPM_VERSION=11.11.1
|
||||
```
|
||||
|
||||
Both the Renovate comment and the pinned ARG are present in the correct order. Renovate will track `npm` releases on `datasource=npm` and propose version bumps automatically.
|
||||
|
||||
### frontend-builder stage (lines 93–115)
|
||||
|
||||
```
|
||||
93: FROM --platform=$BUILDPLATFORM node:24.14.0-alpine@sha256:... AS frontend-builder
|
||||
...
|
||||
105: ARG NPM_VERSION
|
||||
106: # hadolint ignore=DL3017
|
||||
107: RUN apk upgrade --no-cache && \
|
||||
108: npm install -g npm@${NPM_VERSION} --no-fund --no-audit && \
|
||||
109: npm cache clean --force
|
||||
...
|
||||
111: RUN npm ci
|
||||
```
|
||||
|
||||
All structural requirements confirmed: bare re-declaration, lint suppression on dedicated line, three-command RUN, and unmodified `npm ci`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Security Tool Results
|
||||
|
||||
### Trivy config scan
|
||||
|
||||
**Command:** `docker run aquasec/trivy config Dockerfile --severity HIGH,CRITICAL`
|
||||
|
||||
```
|
||||
Report Summary
|
||||
┌────────────┬────────────┬───────────────────┐
|
||||
│ Target │ Type │ Misconfigurations │
|
||||
├────────────┼────────────┼───────────────────┤
|
||||
│ Dockerfile │ dockerfile │ 0 │
|
||||
└────────────┴────────────┴───────────────────┘
|
||||
```
|
||||
|
||||
No HIGH or CRITICAL misconfigurations detected.
|
||||
|
||||
### Hadolint
|
||||
|
||||
**Command:** `docker run hadolint/hadolint < Dockerfile`
|
||||
|
||||
Findings affecting the new code: **none**.
|
||||
|
||||
Pre-existing `info`-level findings (unrelated to this change):
|
||||
|
||||
| Line | Rule | Message |
|
||||
|---|---|---|
|
||||
| 78, 81, 137, 335, 338 | DL3059 info | Multiple consecutive RUN — pre-existing pattern |
|
||||
| 492 | SC2012 info | Use `find` instead of `ls` — unrelated |
|
||||
|
||||
No errors or warnings in the `frontend-builder` section.
|
||||
|
||||
---
|
||||
|
||||
## 3. Logical Security Review
|
||||
|
||||
### Attack surface — build-only stage
|
||||
|
||||
The `frontend-builder` stage is strictly a build artifact producer. The final runtime image receives only compiled frontend assets via a single targeted `COPY`:
|
||||
|
||||
```
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
||||
```
|
||||
|
||||
The Alpine OS packages upgraded by `apk upgrade --no-cache`, the globally installed npm binary, and all `node_modules` are confined to the builder layer and never reach the runtime image. The CVE remediation has zero footprint in the deployed container.
|
||||
|
||||
### `--no-audit` flag
|
||||
|
||||
`--no-audit` suppresses npm audit output during `npm install -g npm@${NPM_VERSION}`. This applies only to the single-package global npm tool upgrade, not to the project dependency installation. `npm ci` on line 111 installs project dependencies from `package-lock.json` and is unaffected by this flag. Suppressing audit during a build-time tool upgrade is the standard pattern for avoiding advisory database noise that cannot be acted on during the image build.
|
||||
|
||||
### `npm cache clean --force`
|
||||
|
||||
Clears the npm package cache between the global npm upgrade and the `npm ci` run. This is safe: it ensures the freshly installed npm binary is used without stale cache entries left by the older npm version bundled in the base image. The `--force` flag suppresses npm's deprecation warning about manual cache cleaning; it does not alter the clean operation itself.
|
||||
|
||||
---
|
||||
|
||||
## Blocking Issues
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
# Supply Chain Security Scan Report — CVE Investigation
|
||||
|
||||
**Date**: 2026-03-19
|
||||
**Scope**: Charon project at `/projects/Charon`
|
||||
**Tools**: Grype 0.109.1, Syft 1.42.2
|
||||
**Go Toolchain**: go1.26.1
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The CVEs flagged for `goxmldsig`, `buger/jsonparser`, and `jackc/pgproto3/v2` are **false positives for the Charon project**. These packages are not in Charon's Go module dependency graph. They originate from Go build info embedded in third-party compiled binaries shipped inside the Docker image — specifically the CrowdSec and Caddy binaries.
|
||||
|
||||
`CVE-2026-33186` (`google.golang.org/grpc`) is **resolved in Charon's own source code** (bumped to v1.79.3), but the same CVE still appears in the SBOM because older grpc versions are embedded in the CrowdSec (`v1.74.2`) and Caddy (`v1.79.1`) binaries in the Docker image. Those are out-of-scope for Charon to patch directly.
|
||||
|
||||
The most actionable findings are stale compiled Charon binaries built with go1.25.4–go1.25.6 that carry Critical/High stdlib CVEs and should be rebuilt with the current go1.26.1 toolchain.
|
||||
|
||||
---
|
||||
|
||||
## 1. Root Cause: Why These Packages Appear in Scans
|
||||
|
||||
### Mechanism: go-module-binary-cataloger
|
||||
|
||||
When Syft generates the SBOM from the Docker image (not from source), it uses the **`go-module-binary-cataloger`** to read embedded Go build info from all compiled Go binaries in the image. Every Go binary compiled since Go 1.18 embeds a complete list of its upstream module dependencies via `debug/buildinfo`.
|
||||
|
||||
This means Syft finds packages from *any* Go binary on the image filesystem — including third-party tools like CrowdSec and Caddy — and reports them as if they were Charon dependencies.
|
||||
|
||||
### Confirmed Binary Sources
|
||||
|
||||
| Package | Version | Binary Path | Binary's Main Module |
|
||||
|---|---|---|---|
|
||||
| `github.com/buger/jsonparser` | v1.1.1 | `/usr/local/bin/crowdsec`, `/usr/local/bin/cscli` | `github.com/crowdsecurity/crowdsec` |
|
||||
| `github.com/jackc/pgproto3/v2` | v2.3.3 | `/usr/local/bin/crowdsec`, `/usr/local/bin/cscli` | `github.com/crowdsecurity/crowdsec` |
|
||||
| `github.com/russellhaering/goxmldsig` | v1.5.0 | `/usr/bin/caddy` | `caddy` |
|
||||
| `google.golang.org/grpc` | v1.74.2 | `/usr/local/bin/crowdsec`, `/usr/local/bin/cscli` | `github.com/crowdsecurity/crowdsec` |
|
||||
| `google.golang.org/grpc` | v1.79.1 | `/usr/bin/caddy` | `caddy` |
|
||||
|
||||
**Verification**: None of these packages appear in `backend/go.mod`, `backend/go.sum`, or the output of `go mod graph`.
|
||||
|
||||
### Why `grype dir:.` Flags Module Cache Artifacts
|
||||
|
||||
Running `grype dir:.` over the Charon workspace also scans `.cache/go/pkg/mod/` — the local Go module download cache. This directory contains the `go.mod` files of every transitively downloaded module. Grype reads those `go.mod` files and flags vulnerable version references within them, even though those versions are not compiled into the Charon binary. All module-cache findings have locations beginning with `/.cache/go/pkg/mod/` and are not exploitable in Charon.
|
||||
|
||||
### Stale SBOM: `sbom-generated.json`
|
||||
|
||||
`sbom-generated.json` (dated **2026-02-21**) was generated by an earlier workflow before the grpc bump and uses a format with no version or PURL data. Grype reading this file matches vulnerabilities against package names alone with no version filter, inflating findings. The authoritative SBOM is `sbom.cyclonedx.json` (dated **2026-03-18**, generated by Syft 1.42.2).
|
||||
|
||||
---
|
||||
|
||||
## 2. CVE-by-CVE Status
|
||||
|
||||
### CVE-2026-33186 — `google.golang.org/grpc`
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Charon source (backend/go.mod)** | v1.79.3 — **PATCHED** ✓ |
|
||||
| **CrowdSec binary (`/usr/local/bin/crowdsec`)** | v1.74.2 — out of scope |
|
||||
| **Caddy binary (`/usr/bin/caddy`)** | v1.79.1 — out of scope |
|
||||
| **False positive for Charon?** | Partially — Charon's own code is patched. SBOM findings persist from Docker image binaries. |
|
||||
|
||||
**Remediation**: Upgrade the CrowdSec and Caddy Docker image versions. The fix in Charon's source is complete.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-479m-364c-43vc — `github.com/russellhaering/goxmldsig` v1.5.0
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **In Charon go.mod / go.sum** | No |
|
||||
| **In go mod graph** | No |
|
||||
| **Source** | `/usr/bin/caddy` binary in Docker image |
|
||||
| **False positive for Charon?** | **Yes** |
|
||||
|
||||
**Remediation**: Requires upgrading the Caddy Docker image tag. Track upstream Caddy release notes for a patched `goxmldsig` dependency.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-6g7g-w4f8-9c9x — `github.com/buger/jsonparser` v1.1.1
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **In Charon go.mod / go.sum** | No |
|
||||
| **In go mod graph** | No |
|
||||
| **Source** | `/usr/local/bin/crowdsec` and `/usr/local/bin/cscli` in Docker image |
|
||||
| **False positive for Charon?** | **Yes** |
|
||||
|
||||
**Remediation**: Requires upgrading the CrowdSec Docker image tag.
|
||||
|
||||
---
|
||||
|
||||
### GHSA-jqcq-xjh3-6g23 — `github.com/jackc/pgproto3/v2` v2.3.3
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **In Charon go.mod / go.sum** | No |
|
||||
| **In go mod graph** | No |
|
||||
| **Source** | `/usr/local/bin/crowdsec` and `/usr/local/bin/cscli` in Docker image |
|
||||
| **False positive for Charon?** | **Yes** |
|
||||
|
||||
**Remediation**: Requires upgrading the CrowdSec Docker image tag.
|
||||
|
||||
---
|
||||
|
||||
## 3. Actionable Findings
|
||||
|
||||
### 3.1 Stdlib CVEs in Stale Charon Binaries (Critical/High)
|
||||
|
||||
Grype found Charon binaries on-disk compiled with old Go versions. The current toolchain is **go1.26.1**, which patches all of the following.
|
||||
|
||||
| Binary | Go Version | Notable CVEs |
|
||||
|---|---|---|
|
||||
| `.trivy_logs/charon_binary` | go1.25.4 (Nov 2025 artifact) | CVE-2025-68121 (Critical), CVE-2025-61726/29/31/32 (High) |
|
||||
| `backend/bin/charon`, `backend/bin/api`, `backend/bin/charon-debug` | go1.25.6 | CVE-2025-68121 (Critical), CVE-2025-61732 (High), CVE-2026-25679 (High) |
|
||||
| `backend/api` (root-level) | go1.25.7 | CVE-2026-25679 (High), CVE-2026-27142 (Medium) |
|
||||
|
||||
**CVE-2025-68121** (Critical, Go stdlib) is the single highest-severity finding in this report.
|
||||
|
||||
**Remediation**: Rebuild all binaries with go1.26.1. Delete `.trivy_logs/charon_binary` (stale Nov 2025 artifact) or add `.trivy_logs/` to `.gitignore`.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Python Virtual Environment Packages (Dev Tooling Only)
|
||||
|
||||
Local `.venv` directories contain outdated packages. These are not shipped in the Docker image.
|
||||
|
||||
| Severity | ID | Package | Fix |
|
||||
|---|---|---|---|
|
||||
| High | GHSA-8rrh-rw8j-w5fx | wheel 0.45.1 | `pip install --upgrade wheel` |
|
||||
| High | GHSA-58pv-8j8x-9vj2 | jaraco-context 5.3.0 | `pip install --upgrade setuptools` |
|
||||
| Medium | GHSA-597g-3phw-6986 | virtualenv 20.35.4 | `pip install --upgrade virtualenv` |
|
||||
| Medium | GHSA-qmgc-5h2g-mvrw / GHSA-w853-jp5j-5j7f | filelock 3.20.0 | `pip install --upgrade filelock` |
|
||||
| Low | GHSA-6vgw-5pg2-w6jp | pip 24.0 / 25.3 | `pip install --upgrade pip` |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Module Cache False Positives (All Confirmed Non-Exploitable)
|
||||
|
||||
Flagged solely because they appear in `go.mod` files inside `.cache/go/pkg/mod/`, not in any compiled Charon binary:
|
||||
|
||||
| ID | Package | Flagged Version | Cache Source | Actual Charon Version |
|
||||
|---|---|---|---|---|
|
||||
| GHSA-p77j-4mvh-x3m3 (Critical) | google.golang.org/grpc | v1.67.0 | `containerd/errdefs/go.mod` | v1.79.3 |
|
||||
| GHSA-9h8m-3fm2-qjrq (High) | go.opentelemetry.io/otel/sdk | v1.38.0 | `otelhttp@v0.63.0/go.mod` | v1.42.0 |
|
||||
| GHSA-47m2-4cr7-mhcw (High) | github.com/quic-go/quic-go | v0.54.0 | `gin-gonic/gin@v1.11.0/go.mod` | not a direct dep |
|
||||
| GHSA-hcg3-q754-cr77 (High) | golang.org/x/crypto | v0.26.0 | `quic-go@v0.54.1/go.mod` | v0.46.0 |
|
||||
| GHSA-cxww-7g56-2vh6 (High) | actions/download-artifact | v4 | `docker/docker` GH workflows in cache | N/A |
|
||||
|
||||
---
|
||||
|
||||
## 4. Scan Configuration Recommendations
|
||||
|
||||
### Exclude Go Module Cache from `grype dir:.`
|
||||
|
||||
Create `.grype.yaml` at project root:
|
||||
|
||||
```yaml
|
||||
ignore:
|
||||
- package:
|
||||
location: "**/.cache/**"
|
||||
- package:
|
||||
location: "**/node_modules/**"
|
||||
```
|
||||
|
||||
Alternatively, scan the SBOM directly rather than the filesystem: `grype sbom:sbom.cyclonedx.json`.
|
||||
|
||||
### Regenerate or Remove `sbom-generated.json`
|
||||
|
||||
`sbom-generated.json` (Feb 21 2026) contains packages with no version or PURL data, causing name-only vulnerability matching. Delete it or regenerate with: `syft scan dir:. -o cyclonedx-json > sbom-generated.json`.
|
||||
|
||||
### Delete or Gitignore `.trivy_logs/charon_binary`
|
||||
|
||||
The 23MB stale binary `.trivy_logs/charon_binary` (go1.25.4, Nov 2025) is a Trivy scan artifact causing several Critical/High CVE findings. Add `.trivy_logs/*.binary` or the whole `.trivy_logs/` directory to `.gitignore`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
| # | Finding | Severity | False Positive? | Action Required |
|
||||
|---|---|---|---|---|
|
||||
| 1 | CVE-2025-68121 in `.trivy_logs/charon_binary` + `backend/bin/*` | **Critical** | No | Rebuild binaries with go1.26.1; delete stale `.trivy_logs/charon_binary` |
|
||||
| 2 | CVE-2026-33186 in Charon source | — | N/A | **Already fixed** (v1.79.3) |
|
||||
| 3 | CVE-2026-33186 in CrowdSec/Caddy binaries | High | Yes (for Charon) | Upgrade CrowdSec and Caddy Docker image tags |
|
||||
| 4 | GHSA-479m-364c-43vc (`goxmldsig`) | Medium | **Yes** | Upgrade Caddy Docker image |
|
||||
| 5 | GHSA-6g7g-w4f8-9c9x (`jsonparser`) | Medium | **Yes** | Upgrade CrowdSec Docker image |
|
||||
| 6 | GHSA-jqcq-xjh3-6g23 (`pgproto3/v2`) | Medium | **Yes** | Upgrade CrowdSec Docker image |
|
||||
| 7 | High stdlib CVEs in `backend/bin/` binaries | High | No | Rebuild with go1.26.1 |
|
||||
| 8 | Python venv packages | Medium | No (dev only) | `pip upgrade` in local envs |
|
||||
| 9 | Module cache false positives | Critical–High | **Yes** | Exclude `.cache/` from `grype dir:.` |
|
||||
| 10 | Stale `sbom-generated.json` | — | Yes | Delete or regenerate |
|
||||
|
||||
172
docs/reports/qa_report_ntfy_notifications.md
Normal file
172
docs/reports/qa_report_ntfy_notifications.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# QA & Security Audit Report: Ntfy Notification Provider
|
||||
|
||||
| Field | Value |
|
||||
|------------------|--------------------------------------|
|
||||
| Date | 2026-03-24 |
|
||||
| Branch | `feature/beta-release` |
|
||||
| Head Commit | `5a2b6fec` |
|
||||
| Feature | Ntfy notification provider |
|
||||
| Verdict | **APPROVED** |
|
||||
|
||||
---
|
||||
|
||||
## Step Summary
|
||||
|
||||
| # | Step | Status | Details |
|
||||
|---|-------------------------------|--------|---------|
|
||||
| 0 | Read security instructions | PASS | security-and-owasp, testing, copilot instructions, SECURITY.md reviewed |
|
||||
| 1 | Rebuild E2E environment | PASS | `skill-runner.sh docker-rebuild-e2e` — container healthy, ports 8080/2020/2019 |
|
||||
| 2 | Playwright E2E tests | PASS | 12/12 ntfy-specific tests passed (Firefox) |
|
||||
| 3 | Local patch report | PASS | 100% patch coverage (0 changed lines vs development) |
|
||||
| 4 | Backend unit coverage | PASS | 88.0% overall (threshold: 85%) |
|
||||
| 5 | Frontend unit coverage | PASS | Lines 90.13%, Statements 89.38%, Functions 86.71%, Branches 81.86% |
|
||||
| 6 | TypeScript type check | PASS | `tsc --noEmit` — zero errors |
|
||||
| 7 | Pre-commit hooks | N/A | Project uses lefthook (not pre-commit); lefthook unavailable in shell |
|
||||
| 8 | GORM security scan | PASS | 0 CRITICAL, 0 HIGH, 0 MEDIUM, 2 INFO (index suggestions only) |
|
||||
| 9 | Security scans (Trivy) | PASS | 0 HIGH/CRITICAL findings in backend or frontend dependencies |
|
||||
| 10 | Linting | PASS | Go: 0 issues (golangci-lint). ESLint: 0 errors, 834 warnings (all pre-existing, 0 ntfy-related) |
|
||||
| 11 | Security code review | PASS | See detailed findings below |
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Playwright E2E Tests (Ntfy)
|
||||
|
||||
**Command**: `npx playwright test --project=firefox tests/settings/ntfy-notification-provider.spec.ts`
|
||||
|
||||
All 12 tests passed in 1.6 minutes:
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Form Rendering — token field and topic URL placeholder | PASS |
|
||||
| Form Rendering — toggle between ntfy and discord | PASS |
|
||||
| Form Rendering — JSON template section | PASS |
|
||||
| CRUD — create with URL and token | PASS |
|
||||
| CRUD — create with URL only (no token) | PASS |
|
||||
| CRUD — edit and preserve token when field left blank | PASS |
|
||||
| CRUD — test notification | PASS |
|
||||
| CRUD — delete provider | PASS |
|
||||
| Security — GET response does NOT expose token | PASS |
|
||||
| Security — token not in URL or visible fields | PASS |
|
||||
| Payload Contract — POST body type/url/token structure | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Backend Unit Coverage
|
||||
|
||||
**Command**: `cd backend && go test -coverprofile=coverage.txt ./...`
|
||||
|
||||
| Package | Coverage |
|
||||
|---------|----------|
|
||||
| services | 86.0% |
|
||||
| handlers | 86.3% |
|
||||
| notifications | 89.4% |
|
||||
| models | 97.5% |
|
||||
| **Overall** | **88.0%** |
|
||||
|
||||
Threshold: 85% — **PASS**
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Frontend Unit Coverage
|
||||
|
||||
**Source**: `frontend/coverage/coverage-summary.json` (163 test files, 1938 tests passed)
|
||||
|
||||
| Metric | Coverage |
|
||||
|--------|----------|
|
||||
| Statements | 89.38% |
|
||||
| Branches | 81.86% |
|
||||
| Functions | 86.71% |
|
||||
| Lines | 90.13% |
|
||||
|
||||
Threshold: 85% line coverage — **PASS**
|
||||
|
||||
---
|
||||
|
||||
## Step 8: GORM Security Scan
|
||||
|
||||
**Command**: `/projects/Charon/scripts/scan-gorm-security.sh --check`
|
||||
|
||||
- Scanned: 43 Go files (2396 lines)
|
||||
- CRITICAL: 0
|
||||
- HIGH: 0
|
||||
- MEDIUM: 0
|
||||
- INFO: 2 (missing FK indexes on `UserPermittedHost.UserID` and `UserPermittedHost.ProxyHostID`)
|
||||
- **Result**: PASSED (no blocking issues)
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Trivy Filesystem Scan
|
||||
|
||||
**Command**: `trivy fs --severity HIGH,CRITICAL --scanners vuln`
|
||||
|
||||
- Backend (`/projects/Charon/backend/`): 0 HIGH/CRITICAL
|
||||
- Frontend (`/projects/Charon/frontend/`): 0 HIGH/CRITICAL
|
||||
- **Result**: PASSED
|
||||
|
||||
Known CVEs from SECURITY.md (all "Awaiting Upstream", not ntfy-related):
|
||||
- CVE-2025-68121 (Critical, CrowdSec Go stdlib)
|
||||
- CVE-2026-2673 (High, OpenSSL in Alpine)
|
||||
- CHARON-2025-001 (High, CrowdSec Go CVEs)
|
||||
- CVE-2026-27171 (Medium, zlib)
|
||||
|
||||
---
|
||||
|
||||
## Step 11: Security Code Review
|
||||
|
||||
### Token Handling
|
||||
|
||||
| Check | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| Token never logged | PASS | `grep -n "log.*[Tt]oken" notification_service.go` — 0 matches |
|
||||
| Token `json:"-"` tag | PASS | `models/notification_provider.go`: `Token string \`json:"-"\`` |
|
||||
| Bearer auth conditional | PASS | Line 593: `if strings.TrimSpace(p.Token) != ""` — only adds header when set |
|
||||
| No hardcoded secrets | PASS | Only test file has `tk_test123` (acceptable) |
|
||||
| Auth header allowed | PASS | `http_wrapper.go` line 465: `"authorization"` in sanitizeOutboundHeaders allowlist |
|
||||
| Token preservation | PASS | Handler update logic includes ntfy in token preservation chain |
|
||||
|
||||
### SSRF Protection
|
||||
|
||||
| Check | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| HTTPWrapper uses SafeHTTPClient | PASS | `http_wrapper.go` line 70: `network.NewSafeHTTPClient(opts...)` |
|
||||
| SafeHTTPClient blocks SSRF | PASS | `safeclient_test.go` line 227: `TestNewSafeHTTPClient_BlocksSSRF` |
|
||||
| Cloud metadata detection | PASS | `url_validator_test.go` line 562: `TestValidateExternalURL_CloudMetadataDetection` |
|
||||
|
||||
The ntfy dispatch path (`dispatchURL = p.URL` → `httpWrapper.Send()`) uses `SafeHTTPClient` at the transport layer, which provides SSRF protection including private IP and cloud metadata blocking.
|
||||
|
||||
### API Security
|
||||
|
||||
| Check | Status |
|
||||
|-------|--------|
|
||||
| Only admin users can create/modify providers | PASS (middleware-enforced) |
|
||||
| Token write-only (never returned in GET) | PASS (E2E test verified) |
|
||||
| `has_token` boolean indicator only | PASS (computed field, `gorm:"-"`) |
|
||||
|
||||
### Gotify Token Protection Policy
|
||||
|
||||
| Check | Status |
|
||||
|-------|--------|
|
||||
| No tokens in logs | PASS |
|
||||
| No tokens in API responses | PASS |
|
||||
| No tokenized URLs in output | PASS |
|
||||
| URL query params redacted in diagnostics | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Issues & Recommendations
|
||||
|
||||
### Blocking Issues
|
||||
|
||||
None.
|
||||
|
||||
### Non-Blocking Observations
|
||||
|
||||
1. **ESLint warnings (834)**: Pre-existing, zero ntfy-related. Recommend gradual cleanup.
|
||||
2. **GORM INFO findings**: Missing indexes on `UserPermittedHost` foreign keys. Non-blocking, performance optimization opportunity.
|
||||
3. **Frontend coverage (branches 81.86%)**: Below 85% but line/statement/function metrics all pass. Branch coverage is inherently lower due to conditional rendering patterns.
|
||||
|
||||
---
|
||||
|
||||
## Final Verdict
|
||||
|
||||
**APPROVED** — The ntfy notification provider implementation passes all mandatory quality and security gates. No blocking issues identified. The feature is ready to ship.
|
||||
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@@ -19,14 +19,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.10.8",
|
||||
"i18next": "^25.10.9",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.0.1",
|
||||
"lucide-react": "^1.6.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.6.5",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tldts": "^7.0.27"
|
||||
@@ -4890,9 +4890,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5555,9 +5555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.322",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.322.tgz",
|
||||
"integrity": "sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==",
|
||||
"version": "1.5.323",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.323.tgz",
|
||||
"integrity": "sha512-oQm+FxbazvN2WICCbvJgj3IYPKV8awip57+W5VP+Aatk4kFU4pDYCPHZOX22Z27zpw8uttBehEqgK+VTJAYrVw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -7780,9 +7780,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.0.4.tgz",
|
||||
"integrity": "sha512-r/9F7wcxiFM71WgDFQiToE2hQHwZ/UkGmr74o8eiNFPIg80f7rlQHVrZiRX46Tj2yE3s96wUVNGMnsDMylgInw==",
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.0.5.tgz",
|
||||
"integrity": "sha512-+i9e/ZKuYlECB5iIK82NQwnYso4oNLBhzsTbXhSqCG1qfGi6D84GNtRENafmS3C0lABX8Wf3BKM434nPXi2AbQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8160,9 +8160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.0.1.tgz",
|
||||
"integrity": "sha512-lih7tKEczCYOQjVEzpFuxEuNzlwf+1yhvlMlEkGWJM3va8Pugv8bYXc/pRtcjPncaP7k84X0Pt/71ufxvqEPtQ==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.6.0.tgz",
|
||||
"integrity": "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -9767,9 +9767,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.6.5",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.5.tgz",
|
||||
"integrity": "sha512-bfdJhmyjQCXtU9CLcGMn3a1V5/jTeUX/x29cOhlS1Lolm/epRtm24gnYsltxArsc29ow3klSJEijjfYXc5kxjg==",
|
||||
"version": "16.6.6",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz",
|
||||
"integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -9777,7 +9777,7 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.6.2",
|
||||
"i18next": ">= 25.10.9",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
|
||||
@@ -38,14 +38,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.10.8",
|
||||
"i18next": "^25.10.9",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.0.1",
|
||||
"lucide-react": "^1.6.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.6.5",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tldts": "^7.0.27"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import client from './client';
|
||||
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover'] as const;
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy'] as const;
|
||||
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
|
||||
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
|
||||
|
||||
@@ -59,7 +59,7 @@ const sanitizeProviderForWriteAction = (data: Partial<NotificationProvider>): Pa
|
||||
|
||||
delete payload.gotify_token;
|
||||
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover') {
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover' && type !== 'ntfy') {
|
||||
delete payload.token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('Security Notification Settings on Notifications page', () => {
|
||||
await user.click(await screen.findByTestId('add-provider-btn'));
|
||||
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement;
|
||||
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover']);
|
||||
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy']);
|
||||
expect(typeSelect.value).toBe('discord');
|
||||
|
||||
const webhookInput = screen.getByTestId('provider-url') as HTMLInputElement;
|
||||
|
||||
@@ -531,7 +531,12 @@
|
||||
"webhookUrl": "Webhook URL (Optional)",
|
||||
"webhookUrlHelp": "POST requests will be sent to this URL when security events occur.",
|
||||
"emailRecipients": "Email Recipients (Optional)",
|
||||
"emailRecipientsHelp": "Comma-separated email addresses."
|
||||
"emailRecipientsHelp": "Comma-separated email addresses.",
|
||||
"ntfy": "Ntfy",
|
||||
"ntfyTopicUrl": "Topic URL",
|
||||
"ntfyAccessToken": "Access Token (optional)",
|
||||
"ntfyAccessTokenPlaceholder": "Enter your Ntfy access token",
|
||||
"ntfyAccessTokenHelp": "Your Ntfy access token for authenticated topics. Required for password-protected topics on self-hosted instances. The token is stored securely and separately."
|
||||
},
|
||||
"users": {
|
||||
"title": "Benutzerverwaltung",
|
||||
|
||||
@@ -628,7 +628,12 @@
|
||||
"pushoverApiTokenPlaceholder": "Enter your Pushover Application API Token",
|
||||
"pushoverUserKey": "User Key",
|
||||
"pushoverUserKeyPlaceholder": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG",
|
||||
"pushoverUserKeyHelp": "Your Pushover user or group key. The API token is stored securely and separately."
|
||||
"pushoverUserKeyHelp": "Your Pushover user or group key. The API token is stored securely and separately.",
|
||||
"ntfy": "Ntfy",
|
||||
"ntfyTopicUrl": "Topic URL",
|
||||
"ntfyAccessToken": "Access Token (optional)",
|
||||
"ntfyAccessTokenPlaceholder": "Enter your Ntfy access token",
|
||||
"ntfyAccessTokenHelp": "Your Ntfy access token for authenticated topics. Required for password-protected topics on self-hosted instances. The token is stored securely and separately."
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
|
||||
@@ -531,7 +531,12 @@
|
||||
"webhookUrl": "Webhook URL (Optional)",
|
||||
"webhookUrlHelp": "POST requests will be sent to this URL when security events occur.",
|
||||
"emailRecipients": "Email Recipients (Optional)",
|
||||
"emailRecipientsHelp": "Comma-separated email addresses."
|
||||
"emailRecipientsHelp": "Comma-separated email addresses.",
|
||||
"ntfy": "Ntfy",
|
||||
"ntfyTopicUrl": "Topic URL",
|
||||
"ntfyAccessToken": "Access Token (optional)",
|
||||
"ntfyAccessTokenPlaceholder": "Enter your Ntfy access token",
|
||||
"ntfyAccessTokenHelp": "Your Ntfy access token for authenticated topics. Required for password-protected topics on self-hosted instances. The token is stored securely and separately."
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestión de Usuarios",
|
||||
|
||||
@@ -531,7 +531,12 @@
|
||||
"webhookUrl": "Webhook URL (Optional)",
|
||||
"webhookUrlHelp": "POST requests will be sent to this URL when security events occur.",
|
||||
"emailRecipients": "Email Recipients (Optional)",
|
||||
"emailRecipientsHelp": "Comma-separated email addresses."
|
||||
"emailRecipientsHelp": "Comma-separated email addresses.",
|
||||
"ntfy": "Ntfy",
|
||||
"ntfyTopicUrl": "Topic URL",
|
||||
"ntfyAccessToken": "Access Token (optional)",
|
||||
"ntfyAccessTokenPlaceholder": "Enter your Ntfy access token",
|
||||
"ntfyAccessTokenHelp": "Your Ntfy access token for authenticated topics. Required for password-protected topics on self-hosted instances. The token is stored securely and separately."
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestion des Utilisateurs",
|
||||
|
||||
@@ -531,7 +531,12 @@
|
||||
"webhookUrl": "Webhook URL (Optional)",
|
||||
"webhookUrlHelp": "POST requests will be sent to this URL when security events occur.",
|
||||
"emailRecipients": "Email Recipients (Optional)",
|
||||
"emailRecipientsHelp": "Comma-separated email addresses."
|
||||
"emailRecipientsHelp": "Comma-separated email addresses.",
|
||||
"ntfy": "Ntfy",
|
||||
"ntfyTopicUrl": "Topic URL",
|
||||
"ntfyAccessToken": "Access Token (optional)",
|
||||
"ntfyAccessTokenPlaceholder": "Enter your Ntfy access token",
|
||||
"ntfyAccessTokenHelp": "Your Ntfy access token for authenticated topics. Required for password-protected topics on self-hosted instances. The token is stored securely and separately."
|
||||
},
|
||||
"users": {
|
||||
"title": "用户管理",
|
||||
|
||||
@@ -23,7 +23,7 @@ const isSupportedProviderType = (providerType: string | undefined): providerType
|
||||
const supportsJSONTemplates = (providerType: string | undefined): boolean => {
|
||||
if (!providerType) return false;
|
||||
const t = providerType.toLowerCase();
|
||||
return t === 'discord' || t === 'gotify' || t === 'webhook' || t === 'telegram' || t === 'slack' || t === 'pushover';
|
||||
return t === 'discord' || t === 'gotify' || t === 'webhook' || t === 'telegram' || t === 'slack' || t === 'pushover' || t === 'ntfy';
|
||||
};
|
||||
|
||||
const isUnsupportedProviderType = (providerType: string | undefined): boolean => !isSupportedProviderType(providerType);
|
||||
@@ -43,7 +43,7 @@ const normalizeProviderPayloadForSubmit = (data: Partial<NotificationProvider>):
|
||||
type,
|
||||
};
|
||||
|
||||
if (type === 'gotify' || type === 'telegram' || type === 'slack' || type === 'pushover') {
|
||||
if (type === 'gotify' || type === 'telegram' || type === 'slack' || type === 'pushover' || type === 'ntfy') {
|
||||
const normalizedToken = typeof payload.gotify_token === 'string' ? payload.gotify_token.trim() : '';
|
||||
|
||||
if (normalizedToken.length > 0) {
|
||||
@@ -149,9 +149,10 @@ const ProviderForm: FC<{
|
||||
const isEmail = type === 'email';
|
||||
const isSlack = type === 'slack';
|
||||
const isPushover = type === 'pushover';
|
||||
const isNtfy = type === 'ntfy';
|
||||
const isNew = !watch('id');
|
||||
useEffect(() => {
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover') {
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover' && type !== 'ntfy') {
|
||||
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
|
||||
}
|
||||
}, [type, setValue]);
|
||||
@@ -209,6 +210,7 @@ const ProviderForm: FC<{
|
||||
<option value="telegram">{t('notificationProviders.telegram')}</option>
|
||||
<option value="slack">{t('notificationProviders.slack')}</option>
|
||||
<option value="pushover">Pushover</option>
|
||||
<option value="ntfy">{t('notificationProviders.ntfy')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -222,7 +224,9 @@ const ProviderForm: FC<{
|
||||
? t('notificationProviders.slackChannelName')
|
||||
: isPushover
|
||||
? t('notificationProviders.pushoverUserKey')
|
||||
: <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
|
||||
: isNtfy
|
||||
? <>{t('notificationProviders.ntfyTopicUrl')} <span aria-hidden="true">*</span></>
|
||||
: <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
|
||||
</label>
|
||||
{isEmail && (
|
||||
<p id="email-recipients-help" className="text-xs text-gray-500 mt-0.5">
|
||||
@@ -236,7 +240,7 @@ const ProviderForm: FC<{
|
||||
validate: (isEmail || isTelegram || isSlack || isPushover) ? undefined : validateUrl,
|
||||
})}
|
||||
data-testid="provider-url"
|
||||
placeholder={isEmail ? 'user@example.com, admin@example.com' : isTelegram ? '987654321' : isSlack ? '#general' : isPushover ? t('notificationProviders.pushoverUserKeyPlaceholder') : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
|
||||
placeholder={isEmail ? 'user@example.com, admin@example.com' : isTelegram ? '987654321' : isSlack ? '#general' : isPushover ? t('notificationProviders.pushoverUserKeyPlaceholder') : type === 'ntfy' ? 'https://ntfy.sh/my-topic' : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
|
||||
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm ${errors.url ? 'border-red-500' : ''}`}
|
||||
aria-invalid={errors.url ? 'true' : 'false'}
|
||||
aria-describedby={isEmail ? 'email-recipients-help' : errors.url ? 'provider-url-error' : undefined}
|
||||
@@ -256,10 +260,10 @@ const ProviderForm: FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isGotify || isTelegram || isSlack || isPushover) && (
|
||||
{(isGotify || isTelegram || isSlack || isPushover || isNtfy) && (
|
||||
<div>
|
||||
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{isPushover ? t('notificationProviders.pushoverApiToken') : isSlack ? t('notificationProviders.slackWebhookUrl') : isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
|
||||
{isNtfy ? t('notificationProviders.ntfyAccessToken') : isPushover ? t('notificationProviders.pushoverApiToken') : isSlack ? t('notificationProviders.slackWebhookUrl') : isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
|
||||
</label>
|
||||
<input
|
||||
id="provider-gotify-token"
|
||||
@@ -267,7 +271,7 @@ const ProviderForm: FC<{
|
||||
autoComplete="new-password"
|
||||
{...register('gotify_token')}
|
||||
data-testid="provider-gotify-token"
|
||||
placeholder={initialData?.has_token ? t('notificationProviders.gotifyTokenKeepPlaceholder') : isPushover ? t('notificationProviders.pushoverApiTokenPlaceholder') : isSlack ? t('notificationProviders.slackWebhookUrlPlaceholder') : isTelegram ? t('notificationProviders.telegramBotTokenPlaceholder') : t('notificationProviders.gotifyTokenPlaceholder')}
|
||||
placeholder={initialData?.has_token ? t('notificationProviders.gotifyTokenKeepPlaceholder') : isNtfy ? t('notificationProviders.ntfyAccessTokenPlaceholder') : isPushover ? t('notificationProviders.pushoverApiTokenPlaceholder') : isSlack ? t('notificationProviders.slackWebhookUrlPlaceholder') : isTelegram ? t('notificationProviders.telegramBotTokenPlaceholder') : t('notificationProviders.gotifyTokenPlaceholder')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
aria-describedby={initialData?.has_token ? 'gotify-token-stored-hint' : undefined}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('react-i18next', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../api/notifications', () => ({
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover'],
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy'],
|
||||
getProviders: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
@@ -148,8 +148,8 @@ describe('Notifications', () => {
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
|
||||
const options = Array.from(typeSelect.options)
|
||||
|
||||
expect(options).toHaveLength(7)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover'])
|
||||
expect(options).toHaveLength(8)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy'])
|
||||
expect(typeSelect.disabled).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1907,9 +1907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.40",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz",
|
||||
"integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==",
|
||||
"version": "0.16.42",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.42.tgz",
|
||||
"integrity": "sha512-sZ4jqyEXfHTLEFK+qsFYToa3UZ0rtFcPGwKpyiRYh2NJn8obPWOQ+/u7ux0F6CAU/y78+Mksh1YkxTPXTh47TQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
|
||||
@@ -112,6 +112,11 @@ test.describe('Notifications Payload Matrix', () => {
|
||||
name: `slack-matrix-${Date.now()}`,
|
||||
url: '#slack-alerts',
|
||||
},
|
||||
{
|
||||
type: 'ntfy',
|
||||
name: `ntfy-matrix-${Date.now()}`,
|
||||
url: 'https://ntfy.sh/my-topic',
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
@@ -134,12 +139,16 @@ test.describe('Notifications Payload Matrix', () => {
|
||||
await page.getByTestId('provider-gotify-token').fill('https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxx');
|
||||
}
|
||||
|
||||
if (scenario.type === 'ntfy') {
|
||||
await page.getByTestId('provider-gotify-token').fill('tk_ntfy_matrix_token');
|
||||
}
|
||||
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('Verify payload contract per provider type', async () => {
|
||||
expect(capturedCreatePayloads).toHaveLength(5);
|
||||
expect(capturedCreatePayloads).toHaveLength(6);
|
||||
|
||||
const discordPayload = capturedCreatePayloads.find((payload) => payload.type === 'discord');
|
||||
expect(discordPayload).toBeTruthy();
|
||||
@@ -167,6 +176,12 @@ test.describe('Notifications Payload Matrix', () => {
|
||||
expect(slackPayload?.token).toBe('https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxx');
|
||||
expect(slackPayload?.gotify_token).toBeUndefined();
|
||||
expect(slackPayload?.url).toBe('#slack-alerts');
|
||||
|
||||
const ntfyPayload = capturedCreatePayloads.find((payload) => payload.type === 'ntfy');
|
||||
expect(ntfyPayload).toBeTruthy();
|
||||
expect(ntfyPayload?.token).toBe('tk_ntfy_matrix_token');
|
||||
expect(ntfyPayload?.gotify_token).toBeUndefined();
|
||||
expect(ntfyPayload?.url).toBe('https://ntfy.sh/my-topic');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -294,8 +294,8 @@ test.describe('Notification Providers', () => {
|
||||
|
||||
await test.step('Verify provider type select contains supported options', async () => {
|
||||
const providerTypeSelect = page.getByTestId('provider-type');
|
||||
await expect(providerTypeSelect.locator('option')).toHaveCount(7);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram', 'Slack', 'Pushover']);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveCount(8);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram', 'Slack', 'Pushover', 'Ntfy']);
|
||||
await expect(providerTypeSelect).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
681
tests/settings/ntfy-notification-provider.spec.ts
Normal file
681
tests/settings/ntfy-notification-provider.spec.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* Ntfy Notification Provider E2E Tests
|
||||
*
|
||||
* Tests the Ntfy notification provider type.
|
||||
* Covers form rendering, CRUD operations, payload contracts,
|
||||
* token security, and validation behavior specific to the Ntfy provider type.
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
||||
import { waitForLoadingComplete } from '../utils/wait-helpers';
|
||||
|
||||
function generateProviderName(prefix: string = 'ntfy-test'): string {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
test.describe('Ntfy Notification Provider', () => {
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
await page.goto('/settings/notifications');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test.describe('Form Rendering', () => {
|
||||
test('should show token field and topic URL placeholder when ntfy type selected', async ({ page }) => {
|
||||
await test.step('Open Add Provider form', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Select ntfy provider type', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('ntfy');
|
||||
});
|
||||
|
||||
await test.step('Verify token field is visible', async () => {
|
||||
await expect(page.getByTestId('provider-gotify-token')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify token field label shows Access Token (optional)', async () => {
|
||||
const tokenLabel = page.getByText(/access token.*optional/i);
|
||||
await expect(tokenLabel.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify topic URL placeholder', async () => {
|
||||
const urlInput = page.getByTestId('provider-url');
|
||||
await expect(urlInput).toHaveAttribute('placeholder', 'https://ntfy.sh/my-topic');
|
||||
});
|
||||
|
||||
await test.step('Verify JSON template section is shown for ntfy', async () => {
|
||||
await expect(page.getByTestId('provider-config')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify save button is accessible', async () => {
|
||||
const saveButton = page.getByTestId('provider-save-btn');
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should toggle form fields correctly when switching between ntfy and discord', async ({ page }) => {
|
||||
await test.step('Open Add Provider form', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify discord is default without token field', async () => {
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('discord');
|
||||
await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Switch to ntfy and verify token field appears', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('ntfy');
|
||||
await expect(page.getByTestId('provider-gotify-token')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch back to discord and verify token field hidden', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('discord');
|
||||
await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show JSON template section for ntfy', async ({ page }) => {
|
||||
await test.step('Open Add Provider form and select ntfy', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('ntfy');
|
||||
});
|
||||
|
||||
await test.step('Verify JSON template config section is visible', async () => {
|
||||
await expect(page.getByTestId('provider-config')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('CRUD Operations', () => {
|
||||
test('should create an ntfy notification provider with URL and token', async ({ page }) => {
|
||||
const providerName = generateProviderName('ntfy-create');
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock create endpoint to capture payload', async () => {
|
||||
const createdProviders: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const payload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
capturedPayload = payload;
|
||||
const { token, gotify_token, ...rest } = payload;
|
||||
const created: Record<string, unknown> = {
|
||||
id: 'ntfy-provider-1',
|
||||
...rest,
|
||||
...(token !== undefined || gotify_token !== undefined ? { has_token: true } : {}),
|
||||
};
|
||||
createdProviders.push(created);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(created),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdProviders),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open form and select ntfy type', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('ntfy');
|
||||
});
|
||||
|
||||
await test.step('Fill ntfy provider form with URL and token', async () => {
|
||||
await page.getByTestId('provider-name').fill(providerName);
|
||||
await page.getByTestId('provider-url').fill('https://ntfy.sh/my-topic');
|
||||
await page.getByTestId('provider-gotify-token').fill('tk_abc123xyz789');
|
||||
});
|
||||
|
||||
await test.step('Configure event notifications', async () => {
|
||||
await page.getByTestId('notify-proxy-hosts').check();
|
||||
await page.getByTestId('notify-certs').check();
|
||||
});
|
||||
|
||||
await test.step('Save provider', async () => {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify provider appears in list', async () => {
|
||||
const providerInList = page.getByText(providerName);
|
||||
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify outgoing payload contract', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('ntfy');
|
||||
expect(capturedPayload?.name).toBe(providerName);
|
||||
expect(capturedPayload?.url).toBe('https://ntfy.sh/my-topic');
|
||||
expect(capturedPayload?.token).toBe('tk_abc123xyz789');
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should create an ntfy notification provider with URL only (no token)', async ({ page }) => {
|
||||
const providerName = generateProviderName('ntfy-notoken');
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock create endpoint to capture payload', async () => {
|
||||
const createdProviders: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const payload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
capturedPayload = payload;
|
||||
const { token, gotify_token, ...rest } = payload;
|
||||
const created: Record<string, unknown> = {
|
||||
id: 'ntfy-notoken-1',
|
||||
...rest,
|
||||
...(token !== undefined || gotify_token !== undefined ? { has_token: true } : {}),
|
||||
};
|
||||
createdProviders.push(created);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(created),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdProviders),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open form and select ntfy type', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('ntfy');
|
||||
});
|
||||
|
||||
await test.step('Fill ntfy provider form with URL only', async () => {
|
||||
await page.getByTestId('provider-name').fill(providerName);
|
||||
await page.getByTestId('provider-url').fill('https://ntfy.sh/public-topic');
|
||||
});
|
||||
|
||||
await test.step('Configure event notifications', async () => {
|
||||
await page.getByTestId('notify-proxy-hosts').check();
|
||||
});
|
||||
|
||||
await test.step('Save provider', async () => {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify provider appears in list', async () => {
|
||||
const providerInList = page.getByText(providerName);
|
||||
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify outgoing payload has no token', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('ntfy');
|
||||
expect(capturedPayload?.name).toBe(providerName);
|
||||
expect(capturedPayload?.url).toBe('https://ntfy.sh/public-topic');
|
||||
expect(capturedPayload?.token).toBeUndefined();
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should edit ntfy provider and preserve token when token field left blank', async ({ page }) => {
|
||||
let updatedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock existing ntfy provider', async () => {
|
||||
let providers = [
|
||||
{
|
||||
id: 'ntfy-edit-id',
|
||||
name: 'Ntfy Alerts',
|
||||
type: 'ntfy',
|
||||
url: 'https://ntfy.sh/my-topic',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(providers),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/*', async (route, request) => {
|
||||
if (request.method() === 'PUT') {
|
||||
updatedPayload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
providers = providers.map((p) =>
|
||||
p.id === 'ntfy-edit-id' ? { ...p, ...updatedPayload } : p
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify ntfy provider is displayed', async () => {
|
||||
await expect(page.getByText('Ntfy Alerts')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Click edit on ntfy provider', async () => {
|
||||
const providerRow = page.getByTestId('provider-row-ntfy-edit-id');
|
||||
const editButton = providerRow.getByRole('button', { name: /edit/i });
|
||||
await expect(editButton).toBeVisible({ timeout: 5000 });
|
||||
await editButton.click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify form loads with ntfy type', async () => {
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('ntfy');
|
||||
});
|
||||
|
||||
await test.step('Verify stored token indicator is shown', async () => {
|
||||
await expect(page.getByTestId('gotify-token-stored-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Update name without changing token', async () => {
|
||||
const nameInput = page.getByTestId('provider-name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('Ntfy Alerts v2');
|
||||
});
|
||||
|
||||
await test.step('Save changes', async () => {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers\/ntfy-edit-id/.test(resp.url()) &&
|
||||
resp.request().method() === 'PUT' &&
|
||||
resp.status() === 200
|
||||
),
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'GET' &&
|
||||
resp.status() === 200
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify update payload preserves token omission', async () => {
|
||||
expect(updatedPayload).toBeTruthy();
|
||||
expect(updatedPayload?.type).toBe('ntfy');
|
||||
expect(updatedPayload?.name).toBe('Ntfy Alerts v2');
|
||||
expect(updatedPayload?.token).toBeUndefined();
|
||||
expect(updatedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should test an ntfy notification provider', async ({ page }) => {
|
||||
let testCalled = false;
|
||||
|
||||
await test.step('Mock existing ntfy provider and test endpoint', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'ntfy-test-id',
|
||||
name: 'Ntfy Test Provider',
|
||||
type: 'ntfy',
|
||||
url: 'https://ntfy.sh/my-topic',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/test', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
testCalled = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Click Send Test on the provider', async () => {
|
||||
const providerRow = page.getByTestId('provider-row-ntfy-test-id');
|
||||
const sendTestButton = providerRow.getByRole('button', { name: /send test/i });
|
||||
await expect(sendTestButton).toBeVisible({ timeout: 5000 });
|
||||
await expect(sendTestButton).toBeEnabled();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/api/v1/notifications/providers/test') &&
|
||||
resp.status() === 200
|
||||
),
|
||||
sendTestButton.click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify test was called', async () => {
|
||||
expect(testCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should delete an ntfy notification provider', async ({ page }) => {
|
||||
await test.step('Mock existing ntfy provider', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'ntfy-delete-id',
|
||||
name: 'Ntfy To Delete',
|
||||
type: 'ntfy',
|
||||
url: 'https://ntfy.sh/my-topic',
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/*', async (route, request) => {
|
||||
if (request.method() === 'DELETE') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify ntfy provider is displayed', async () => {
|
||||
await expect(page.getByText('Ntfy To Delete')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Delete provider', async () => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('confirm');
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const deleteButton = page.getByRole('button', { name: /delete/i })
|
||||
.or(page.locator('button').filter({ has: page.locator('svg.lucide-trash2, svg[class*="trash"]') }));
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/api/v1/notifications/providers/ntfy-delete-id') &&
|
||||
resp.status() === 200
|
||||
),
|
||||
deleteButton.first().click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify deletion feedback', async () => {
|
||||
const successIndicator = page.locator('[data-testid="toast-success"]')
|
||||
.or(page.getByRole('status').filter({ hasText: /deleted|removed/i }))
|
||||
.or(page.getByText(/no.*providers/i));
|
||||
await expect(successIndicator.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Security', () => {
|
||||
test('GET response should NOT expose the access token value', async ({ page }) => {
|
||||
let apiResponseBody: Array<Record<string, unknown>> | null = null;
|
||||
|
||||
let resolveRouteBody: (data: Array<Record<string, unknown>>) => void;
|
||||
const routeBodyPromise = new Promise<Array<Record<string, unknown>>>((resolve) => {
|
||||
resolveRouteBody = resolve;
|
||||
});
|
||||
|
||||
await test.step('Mock provider list with has_token flag', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
const body = [
|
||||
{
|
||||
id: 'ntfy-sec-id',
|
||||
name: 'Ntfy Secure',
|
||||
type: 'ntfy',
|
||||
url: 'https://ntfy.sh/my-topic',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
resolveRouteBody!(body);
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Navigate to trigger GET', async () => {
|
||||
await page.reload();
|
||||
apiResponseBody = await Promise.race([
|
||||
routeBodyPromise,
|
||||
new Promise<Array<Record<string, unknown>>>((_resolve, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timed out waiting for GET /api/v1/notifications/providers')),
|
||||
15000
|
||||
)
|
||||
),
|
||||
]);
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify access token is not in API response', async () => {
|
||||
expect(apiResponseBody).toBeTruthy();
|
||||
const provider = apiResponseBody![0];
|
||||
expect(provider.token).toBeUndefined();
|
||||
expect(provider.gotify_token).toBeUndefined();
|
||||
const responseStr = JSON.stringify(provider);
|
||||
expect(responseStr).not.toContain('tk_abc123xyz789');
|
||||
});
|
||||
});
|
||||
|
||||
test('access token should not appear in the url field or any visible field', async ({ page }) => {
|
||||
await test.step('Mock provider with clean URL field', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'ntfy-url-sec-id',
|
||||
name: 'Ntfy URL Check',
|
||||
type: 'ntfy',
|
||||
url: 'https://ntfy.sh/my-topic',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload and verify access token does not appear in provider row', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
await expect(page.getByText('Ntfy URL Check')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const providerRow = page.getByTestId('provider-row-ntfy-url-sec-id');
|
||||
const urlText = await providerRow.textContent();
|
||||
expect(urlText).not.toContain('tk_abc123xyz789');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Payload Contract', () => {
|
||||
test('POST body should include type=ntfy, url field = topic URL, token field is write-only', async ({ page }) => {
|
||||
const providerName = generateProviderName('ntfy-contract');
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
let capturedGetResponse: Array<Record<string, unknown>> | null = null;
|
||||
|
||||
await test.step('Mock create and list endpoints', async () => {
|
||||
const createdProviders: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const payload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
capturedPayload = payload;
|
||||
const { token, gotify_token, ...rest } = payload;
|
||||
const created: Record<string, unknown> = {
|
||||
id: 'ntfy-contract-1',
|
||||
...rest,
|
||||
has_token: !!(token || gotify_token),
|
||||
};
|
||||
createdProviders.push(created);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(created),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
capturedGetResponse = [...createdProviders];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdProviders),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Create an ntfy provider via the UI', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('ntfy');
|
||||
await page.getByTestId('provider-name').fill(providerName);
|
||||
await page.getByTestId('provider-url').fill('https://ntfy.sh/my-topic');
|
||||
await page.getByTestId('provider-gotify-token').fill('tk_abc123xyz789');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify POST payload: type=ntfy, url=topic URL, token=access token', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('ntfy');
|
||||
expect(capturedPayload?.url).toBe('https://ntfy.sh/my-topic');
|
||||
expect(capturedPayload?.token).toBe('tk_abc123xyz789');
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
|
||||
await test.step('Verify GET response: has_token=true, token value absent', async () => {
|
||||
await expect(page.getByText(providerName).first()).toBeVisible({ timeout: 10000 });
|
||||
expect(capturedGetResponse).toBeTruthy();
|
||||
const provider = capturedGetResponse![0];
|
||||
expect(provider.has_token).toBe(true);
|
||||
expect(provider.token).toBeUndefined();
|
||||
expect(provider.gotify_token).toBeUndefined();
|
||||
const responseStr = JSON.stringify(provider);
|
||||
expect(responseStr).not.toContain('tk_abc123xyz789');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user