diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index f3dca075..9029ac24 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -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: diff --git a/.grype.yaml b/.grype.yaml index 8e2e1e71..945b8297 100644 --- a/.grype.yaml +++ b/.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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 780670df..edcc6bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) diff --git a/Dockerfile b/Dockerfile index bd8d9f4e..a435aa5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 96c6c2bc..ec4df8b2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,101 +27,181 @@ public disclosure. ## Known Vulnerabilities -### [CRITICAL] CVE-2025-68121 · Go Stdlib Critical in CrowdSec Bundled Binaries +Last reviewed: 2026-03-24 + +### [HIGH] CVE-2026-2673 · OpenSSL TLS 1.3 Key Exchange Group Downgrade | Field | Value | |--------------|-------| -| **ID** | CVE-2025-68121 (see also CHARON-2025-001) | -| **Severity** | Critical | +| **ID** | CVE-2026-2673 (affects `libcrypto3` and `libssl3`) | +| **Severity** | High · 7.5 | | **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. +An OpenSSL TLS 1.3 server may fail to negotiate the intended key exchange group when the +configuration includes the `DEFAULT` keyword, potentially allowing downgrade to weaker cipher +suites. Affects Alpine 3.23.3 packages `libcrypto3` and `libssl3` at version 3.5.5-r0. **Who** - Discovered by: Automated scan (Grype) - Reported: 2026-03-20 +- Affects: Container runtime environment; Caddy reverse proxy TLS negotiation could be affected + if default key group configuration is used + +**Where** + +- Component: Alpine 3.23.3 base image (`libcrypto3` 3.5.5-r0, `libssl3` 3.5.5-r0) +- Versions affected: Alpine 3.23.3 prior to a patched `openssl` APK release + +**When** + +- Discovered: 2026-03-20 +- Disclosed (if public): 2026-03-13 (OpenSSL advisory) +- Target fix: When Alpine Security publishes a patched `openssl` APK + +**How** +When an OpenSSL TLS 1.3 server configuration uses the `DEFAULT` keyword for key exchange groups, +the negotiation logic may select a weaker group than intended. Charon's Caddy TLS configuration +does not use the `DEFAULT` keyword, which limits practical exploitability. The packages are +present in the base image regardless of Caddy's configuration. + +**Planned Remediation** +Monitor 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 libcrypto3 libssl3` to the runtime stage. + +--- + +### [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: Charon container images with CrowdSec binaries compiled against Go < 1.25.7 +- Versions affected: CrowdSec builds using `filippo.io/edwards25519` < v1.1.1 **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 +- Discovered: 2026-03-24 +- Disclosed (if public): Public +- Target fix: When CrowdSec releases a build with updated dependency **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. +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** -`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. +Awaiting CrowdSec upstream release with updated dependency. No action available for Charon +maintainers. --- -### [HIGH] CVE-2026-2673 · OpenSSL TLS 1.3 Key Exchange Group Downgrade +## Patched Vulnerabilities + +### ✅ [CRITICAL] CVE-2025-68121 · Go Stdlib Critical in CrowdSec Bundled Binaries | Field | Value | |--------------|-------| -| **ID** | CVE-2026-2673 (affects `libcrypto3` and `libssl3`) | -| **Severity** | High · 7.5 | -| **Status** | Awaiting Upstream | +| **ID** | CVE-2025-68121 (see also CHARON-2025-001) | +| **Severity** | Critical | +| **Patched** | 2026-03-24 | **What** -An OpenSSL TLS 1.3 server may fail to negotiate the intended key exchange group when the -configuration includes the `DEFAULT` keyword, potentially allowing downgrade to weaker cipher -suites. Affects Alpine 3.23.3 packages `libcrypto3` and `libssl3` at version 3.5.5-r0. +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: Container runtime environment; Caddy reverse proxy TLS negotiation could be affected - if default key group configuration is used **Where** -- Component: Alpine 3.23.3 base image (`libcrypto3` 3.5.5-r0, `libssl3` 3.5.5-r0) -- Versions affected: Alpine 3.23.3 prior to a patched `openssl` APK release +- 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): 2026-03-13 (OpenSSL advisory) -- Target fix: When Alpine Security publishes a patched `openssl` APK +- Patched: 2026-03-24 +- Time to patch: 4 days **How** -When an OpenSSL TLS 1.3 server configuration uses the `DEFAULT` keyword for key exchange groups, -the negotiation logic may select a weaker group than intended. Charon's Caddy TLS configuration -does not use the `DEFAULT` keyword, which limits practical exploitability. The packages are -present in the base image regardless of Caddy's configuration. +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** -Monitor 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 libcrypto3 libssl3` to the runtime stage. +**Resolution** +CrowdSec binaries now compiled with Go 1.26.1 (was 1.25.6). --- -### [HIGH] CHARON-2025-001 · CrowdSec Bundled Binaries — Go Stdlib CVEs +### ✅ [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 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 diff --git a/backend/go.mod b/backend/go.mod index 1bc37480..44a2e22b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 500ab1c9..5c30b306 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 404f0ea7..9eeb4847 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -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 diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index 47e3250c..8b16cf2f 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -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 } diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/notifications/feature_flags.go index e5cf0f2c..846a78cb 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/notifications/feature_flags.go @@ -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" ) diff --git a/backend/internal/notifications/http_wrapper.go b/backend/internal/notifications/http_wrapper.go index 7ed876ea..e9831e2c 100644 --- a/backend/internal/notifications/http_wrapper.go +++ b/backend/internal/notifications/http_wrapper.go @@ -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) diff --git a/backend/internal/notifications/http_wrapper_test.go b/backend/internal/notifications/http_wrapper_test.go index 3df06cd4..765cfa14 100644 --- a/backend/internal/notifications/http_wrapper_test.go +++ b/backend/internal/notifications/http_wrapper_test.go @@ -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") diff --git a/backend/internal/notifications/router.go b/backend/internal/notifications/router.go index f15142dc..5aa78076 100644 --- a/backend/internal/notifications/router.go +++ b/backend/internal/notifications/router.go @@ -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 } diff --git a/backend/internal/notifications/router_test.go b/backend/internal/notifications/router_test.go index 4a7fa17d..25395dba 100644 --- a/backend/internal/notifications/router_test.go +++ b/backend/internal/notifications/router_test.go @@ -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") + } +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 5ae56532..f54eb48a 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -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 } diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 7c84a3e3..2979cd5e 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -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") +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 6c84b784..1c81dfc1 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -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")) +} diff --git a/docs/features.md b/docs/features.md index 3ff05722..139348d8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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) diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 166db0c8..9166d18b 100644 --- a/docs/features/notifications.md +++ b/docs/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 diff --git a/docs/issues/ntfy-notification-provider-manual-testing.md b/docs/issues/ntfy-notification-provider-manual-testing.md new file mode 100644 index 00000000..51d38eb5 --- /dev/null +++ b/docs/issues/ntfy-notification-provider-manual-testing.md @@ -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` diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 6f75ac16..91fe5bed 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -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** () 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 + +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. + +**Key code paths per provider type:** + +| 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` | `