Skip to content

feat(admin): rework the health check and verify the protected-pad cookie - #179

Merged
Jaggob merged 19 commits into
mainfrom
feat/protected-pads-cookie-check
Aug 1, 2026
Merged

feat(admin): rework the health check and verify the protected-pad cookie#179
Jaggob merged 19 commits into
mainfrom
feat/protected-pads-cookie-check

Conversation

@Jaggob

@Jaggob Jaggob commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

The connection test gave one verdict for everything and only ever contacted the API host. It now reports per checked part – API, API key, base URL, session cookie – each shown at the field it came from, with a green tick where things are fine and the reason where they are not.

Two gaps that hid behind the single verdict:

The base URL was never checked. Nothing contacts it except the browser, so a typo there broke every pad link while the test stayed green. It is checked now, but never fails the test: Nextcloud may be unable to reach the public URL by design (split-horizon DNS, egress firewall), so an unreachable base URL warns and says so.

Protected pads could be impossible and nobody said so. They need a session cookie valid for both hosts. Nextcloud issues it from its own response, and a browser only accepts a Domain= covering the sending host – so the two must share a parent domain or run on the same host. SameSite does not lift that restriction.

Checking it surfaced a real bug: two derivations existed and disagreed in exactly the failing case. The settings page prefilled from the common parent of both hosts, while the runtime derived from the Etherpad host alone and could emit a Domain= the browser discards. CookieDomainPolicy makes that decision once; runtime, settings page and health check read the same object. Where both worked before, the shared parent is the narrower of the two values. When a configured domain is wrong, the message names one that would work.

The summary no longer says "successful" – that read as an all-clear when it only meant the request went through. It now reports what was found, and a failure marks the input that caused it instead of appearing only at the bottom.

Docs corrected alongside: the cookie domain field is no longer marked optional, and README no longer suggests unrelated registrable domains work with cookie.sameSite: "None".

Closes #176

Jaggob added 14 commits August 1, 2026 11:53
"Test Etherpad connection" only called listAllPads, so it reported success
on instances where protected pads could never work: Nextcloud issues the
Etherpad session cookie from its own response, and a browser only accepts
a Domain= that covers the sending host. Nextcloud and Etherpad therefore
have to share a parent domain, or run on the same host.

The two derivations that existed disagreed in exactly that case. The admin
page prefilled from the common parent of both hosts, while the runtime
derived from the Etherpad host alone and could emit a Domain= the browser
discards. CookieDomainPolicy now makes that decision once, and the
runtime, the settings page and the health check all read the same object,
so what is set and what is reported cannot drift apart.

Where both worked before, the shared parent is the narrower of the two
values, e.g. .pad.example.org instead of .example.org for a Nextcloud on
cloud.pad.example.org.

The verdict is a warning beside the connection result, never folded into
it, and only evaluated when protected pads are enabled. It is pure string
comparison with no I/O, so the settings page can show it without running
a test first.

Public suffixes are recognised by a conservative heuristic rather than a
Public Suffix List lookup — the app ships no runtime dependencies to
carry one — so that case reports "may be" instead of a verdict.

Corrects the docs alongside: the cookie domain field is no longer marked
optional, and README no longer suggests that unrelated registrable
domains work with cookie.sameSite "None". SameSite governs whether an
accepted cookie is sent cross-site, not which domain may be set.

Closes #176
- The health check read the stored protected-pads toggle while testing the
  submitted form values, so an unsaved change was reported against the
  wrong configuration in both directions. It now uses the submitted
  setting, and no longer needs PadTypePolicy at all.
- The legacy fallback for a domain stored before the "was configured" flag
  existed lived only in PadSessionService, so the admin page and the
  runtime still fed the shared policy different inputs. It moved into
  CookieDomainPolicy::storedValue(), which every caller goes through.
- Saving now returns the recomputed protected_pads block and the page
  applies it, so a corrected domain no longer keeps showing the old
  warning, and a newly broken one shows up without a reload.
persist() always wrote etherpad_cookie_domain_configured=yes, discarding
what the validator had determined. A client omitting the optional field
therefore had an empty value pinned as a deliberate host-only cookie,
while the save response still described a derived domain. The browser
form always submits the field, so only the admin API path was affected —
which does support omitted optional fields.
EtherpadHealthCheckService now returns the decision only; the controller,
which already builds the payload, renders the text. That drops the
CookieDomainMessages dependency, the cookieDomainMessage field on
HealthCheckResult, and the corresponding constructor arguments in three
test fixtures.

The two health-check tests asserting the submitted protected-pads toggle
duplicated the pair already covering it. Renamed the originals to say what
they guard and removed the copies. The remaining case now saves no cookie
domain, so it exercises the derived path rather than tripping over the
fixture's configured one.

Adds assertions for the protected_pads contract to the existing save and
health controller tests, and corrects a comment in AdminSettings: the
field shows the saved value, which is not always what gets sent — with
identical hosts the policy uses a host-only cookie regardless.
The connection test checks the base URL, API key and API URL, but sat at
the bottom of the page behind three unrelated sections. It now follows the
fields it verifies, with its own result area, so a freshly entered server
can be checked without scrolling past everything else. Diagnostics keeps
the consistency check and the pending-delete retry.

The section hint covered both checks in one sentence and is split
accordingly; the connection half now says that it works on the entered
values whether or not they were saved, which is the reason to have the
button there.

Result areas share one callout shape — coloured rule, tinted panel — in
green for success, red for failure and amber for the cookie warning. A
check still running has no verdict, so it stays uncoloured. The rule
carries the colour rather than the text: Nextcloud's success green is
marginal as body text, and the messages already state the outcome in
words, so colour never carries it alone.
One combined verdict hid what it did not cover. The test only contacts the
API host, so a typo in the base URL — which every pad link in the browser
uses — still read as a clean pass.

The result is now a list with a line each for API reachability, the API
key, the base URL and the protected-pads session cookie. The protected
pads line carries the wording that used to sit in a separate callout, so
the outcome and its caveat no longer appear as two disconnected messages.

The base URL line is new and the only one doing extra I/O: a short GET
against the browser-facing URL. It never fails the test. Nextcloud may be
unable to reach that URL by design — split-horizon DNS, an egress
firewall, a proxy serving only outside traffic — so it warns and says so
rather than crying wolf on a correct instance. Any answer counts as
reachable, including 401/403/404, since that already proves the name
resolves and something listens.

The standalone cookie warning stays for the page load and after saving,
where no list exists; the two swap places so the same statement is never
shown twice.
Every check maps to exactly one input, so the verdict now appears there:
a green tick where things are fine, the reason itself where they are not.
The list under the button remains only as a fallback for lines belonging
to no single field. An empty API URL falls back to the base URL, so the
API line points at whichever field actually supplied the address.

The summary said "Etherpad connection test successful", which reads as
"everything is fine" when it only meant the request went through — the
very confusion the per-check list exists to remove. It now reports what
was found: all checks passed, or some settings need attention. The metrics
it used to append moved into the API line, where the target belongs
anyway.
Only the tick is green now; the text uses the muted body colour. The
success colour is too light to read as body text — something this branch
already noted before using it for a whole line anyway.

The fallback list under the button could never render: every check carries
a field and every field has a slot. Removed, along with its markup, styles
and the row builder.

The cookie verdict had two mechanisms — a standalone callout and a field
result. Saving now answers in the same shape the connection test uses, the
initial page render writes into the same slot, and the protected-pads line
is built once in CookieDomainMessages for both callers. One path, so the
same statement can no longer appear twice or drift.

Two health-check tests covered the same scenario as their neighbours from
a second angle; merged into the originals.
A wrong domain told the admin it was wrong and left them to work out the
right one. The policy now derives the domain these two hosts could share
and carries it on the decision, so the message ends with "… would cover
both". Nothing is suggested when there is nothing to suggest — unrelated
hosts, or a shared parent that looks like a public suffix.

The no-common-parent message also names the third way out it was missing:
a proxy that already serves Etherpad under the Nextcloud domain works as
soon as that address is the base URL, because pad links use it and the
cookie follows.
- The base URL check assumed request() returns a response for 4xx/5xx, but
  Nextcloud's client throws — which is why EtherpadClient already recovers
  it. A reachable host answering 404 was therefore reported as "did not
  answer". It now uses the same recovery, and reports the original
  transport error rather than the recovery failure when there is no
  response at all. Tests cover both shapes.
- A skipped base URL line claimed the same field as the API line, so with
  no separate API URL "Same as the API URL" overwrote the API verdict.
  Skipped lines carry no field now.
- Field verdicts were cleared only once a response arrived, leaving green
  ticks beside "Testing…" and surviving a failed save. Both actions clear
  them before the request; the consistency check does not, since it
  changes nothing. Covered with a deferred request rather than an
  immediate one.
- A rejected API key or an unreachable API host threw without a field, so
  the most important failure appeared only in the summary. The existing
  classification now also yields the field: the key for auth failures, the
  effective API host field for transport errors and 404.
- Removed a duplicated pendingActions assignment.
- Any 4xx now warns instead of counting as reachable. A base URL with a
  wrong path answers 404 while every pad link built from it is broken, and
  the summary still said all checks passed.
- The health result could contradict itself: the service produced the
  protected-pads line while the controller held the decision, so a warning
  decision could sit beside an empty check list and an "all passed"
  summary. The service returns the decision and the other lines; the
  controller appends the protected-pads line once and feeds the same list
  to both the summary and the payload. CookieDomainMessages is no longer a
  dependency of the health service.
- The error mapper test now asserts the field it forwards, and that it is
  omitted when there is none.

Removals: the old-markup fallbacks for the two status areas, since
template and bundle ship together; the health_ok string, its data
attribute and catalog entries, now that the endpoint always sends its own
summary; and the paragraph in api-reference.md that repeated the cookie
explanation from README.
The previous cleanup spliced the new text in without removing what it
replaced, leaving the protected_pads block twice and the long cookie
paragraph still there. Worse, the indentation put protected_pads under the
502 response, which carries only message and an optional field. One block
now, correctly nested, pointing at README for the reasoning; the 502 note
says explicitly that nothing else is on that response.

HealthCheckResult loses the defaults on cookieDomain and checks — both
call sites pass them, and without a default nobody can build a half-filled
result by accident.
The hint and the field were derived by two separate matchers over the same
error strings, so a new Etherpad message could produce a correct hint with
the wrong field. classifyFailure() decides once and both are looked up
from the reason; the two test providers become one table asserting
message, hint and field together.

Adds CookieDomainMessagesTest: a provider drives the policy into each
warning it can produce and asserts the rendered line is a warning, is not
blank and names the hosts or domain involved, without pinning the wording.
asCheckItem() now falls back to the hosts sentence when a reason has no
text, so a reason added later cannot appear as a blank warning.

Adds AdminSettingsTest for the initial page render — the warning must be
there before anyone runs a test, which is why the check does no I/O — with
stubs for OCP\Util and OCP\Settings\ISettings.

Removals: the empty-base-URL branch and its translations, unreachable
because the validator rejects it; a test whose EtherpadClient mock broke
the guaranteed return shape; the unused lineById() helper; a stale comment
claiming a 404 proves reachability. The redirect test asserted nothing
about redirects and now pins the request options instead.
Merging the two matchers dropped a generic case: the old field logic
marked the key field for any message containing "api key" or "apikey",
while the new classification only recognised the specific wordings and
401/403. "API key file could not be read" therefore lost its field. A
last-resort reason restores it — placed last so it can never shadow a more
specific reason and its hint, and carrying no hint of its own since there
is nothing to advise.

The fallback for an unmapped cookie reason now says the domain could not
be verified rather than only naming the hosts, so a forgotten mapping
reads as a gap instead of hiding behind a bare fact.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. SSRF in base URL check ✓ Resolved 🐞 Bug ⛨ Security
Description
BaseUrlReachabilityCheck performs a server-side GET to the configured Etherpad Base URL while
explicitly enabling local-address access and following redirects, bypassing Nextcloud’s usual SSRF
protections. A malicious/compromised admin (or any bug that lets an attacker trigger this admin-only
endpoint with crafted parameters) can make the Nextcloud server probe internal/loopback endpoints.
Code

lib/Service/BaseUrlReachabilityCheck.php[R52-58]

+			$response = $client->request('GET', $trimmed, [
+				'timeout' => self::TIMEOUT_SECONDS,
+				// Etherpad redirects / to /p/... on some setups, and a proxy may
+				// redirect http to https. Following a couple is normal here.
+				'allow_redirects' => ['max' => 3],
+				'nextcloud' => ['allow_local_address' => true],
+			]);
Relevance

●●● Strong

Team has history of accepting URL hardening/validation; SSRF-risk reduction likely welcomed despite
admin-only context.

PR-#13
PR-#34

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new reachability check issues a GET with redirects enabled and allow_local_address explicitly
set, and it is invoked from the admin-triggered health check using the submitted Etherpad Base URL.
The validator enforces URL shape/scheme but does not prevent localhost/private IP targets, and the
browser UI always submits etherpad_host, so the server will attempt to fetch whatever the admin
enters.

lib/Service/BaseUrlReachabilityCheck.php[39-58]
lib/Service/EtherpadHealthCheckService.php[96-123]
lib/Controller/AdminController.php[89-98]
lib/Service/AdminSettingsValidator.php[100-118]
src/admin-settings.js[95-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`BaseUrlReachabilityCheck::check()` makes an HTTP request to a URL derived from admin-submitted settings and sets `nextcloud.allow_local_address=true` while also enabling redirects. This combination creates an SSRF primitive (including via redirects) that defeats Nextcloud’s built-in local-address blocking.
### Issue Context
This request runs in the admin health-check flow and uses the current (unsaved) form values.
### Fix Focus Areas
- lib/Service/BaseUrlReachabilityCheck.php[39-59]
- lib/Service/EtherpadHealthCheckService.php[96-123]
### Suggested fix approach
- Prefer **not** setting `nextcloud => ['allow_local_address' => true]` here; let the default local-address protections apply.
- Consider setting `allow_redirects` to `false` (treat 3xx as “reachable”) or enforce **same-host-only redirects** and re-check each redirect target against local/private ranges.
- If local/internal Etherpad base URLs are a supported deployment, gate local-address access behind an explicit admin opt-in setting and/or validate resolved IPs against private/link-local/loopback ranges before allowing the request.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. SSRF in base URL check ✓ Resolved 🐞 Bug ⛨ Security
Description
BaseUrlReachabilityCheck performs a server-side GET to the configured Etherpad Base URL while
explicitly enabling local-address access and following redirects, bypassing Nextcloud’s usual SSRF
protections. A malicious/compromised admin (or any bug that lets an attacker trigger this admin-only
endpoint with crafted parameters) can make the Nextcloud server probe internal/loopback endpoints.
Code

lib/Service/BaseUrlReachabilityCheck.php[R52-58]

+			$response = $client->request('GET', $trimmed, [
+				'timeout' => self::TIMEOUT_SECONDS,
+				// Etherpad redirects / to /p/... on some setups, and a proxy may
+				// redirect http to https. Following a couple is normal here.
+				'allow_redirects' => ['max' => 3],
+				'nextcloud' => ['allow_local_address' => true],
+			]);
Relevance

●●● Strong

Team has history of accepting URL hardening/validation; SSRF-risk reduction likely welcomed despite
admin-only context.

PR-#13
PR-#34

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new reachability check issues a GET with redirects enabled and allow_local_address explicitly
set, and it is invoked from the admin-triggered health check using the submitted Etherpad Base URL.
The validator enforces URL shape/scheme but does not prevent localhost/private IP targets, and the
browser UI always submits etherpad_host, so the server will attempt to fetch whatever the admin
enters.

lib/Service/BaseUrlReachabilityCheck.php[39-58]
lib/Service/EtherpadHealthCheckService.php[96-123]
lib/Controller/AdminController.php[89-98]
lib/Service/AdminSettingsValidator.php[100-118]
src/admin-settings.js[95-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`BaseUrlReachabilityCheck::check()` makes an HTTP request to a URL derived from admin-submitted settings and sets `nextcloud.allow_local_address=true` while also enabling redirects. This combination creates an SSRF primitive (including via redirects) that defeats Nextcloud’s built-in local-address blocking.
### Issue Context
This request runs in the admin health-check flow and uses the current (unsaved) form values.
### Fix Focus Areas
- lib/Service/BaseUrlReachabilityCheck.php[39-59]
- lib/Service/EtherpadHealthCheckService.php[96-123]
### Suggested fix approach
- Prefer **not** setting `nextcloud => ['allow_local_address' => true]` here; let the default local-address protections apply.
- Consider setting `allow_redirects` to `false` (treat 3xx as “reachable”) or enforce **same-host-only redirects** and re-check each redirect target against local/private ranges.
- If local/internal Etherpad base URLs are a supported deployment, gate local-address access behind an explicit admin opt-in setting and/or validate resolved IPs against private/link-local/loopback ranges before allowing the request.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. SSRF in base URL check ✓ Resolved 🐞 Bug ⛨ Security
Description
BaseUrlReachabilityCheck performs a server-side GET to the configured Etherpad Base URL while
explicitly enabling local-address access and following redirects, bypassing Nextcloud’s usual SSRF
protections. A malicious/compromised admin (or any bug that lets an attacker trigger this admin-only
endpoint with crafted parameters) can make the Nextcloud server probe internal/loopback endpoints.
Code

lib/Service/BaseUrlReachabilityCheck.php[R52-58]

+			$response = $client->request('GET', $trimmed, [
+				'timeout' => self::TIMEOUT_SECONDS,
+				// Etherpad redirects / to /p/... on some setups, and a proxy may
+				// redirect http to https. Following a couple is normal here.
+				'allow_redirects' => ['max' => 3],
+				'nextcloud' => ['allow_local_address' => true],
+			]);
Relevance

●●● Strong

Team has history of accepting URL hardening/validation; SSRF-risk reduction likely welcomed despite
admin-only context.

PR-#13
PR-#34

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new reachability check issues a GET with redirects enabled and allow_local_address explicitly
set, and it is invoked from the admin-triggered health check using the submitted Etherpad Base URL.
The validator enforces URL shape/scheme but does not prevent localhost/private IP targets, and the
browser UI always submits etherpad_host, so the server will attempt to fetch whatever the admin
enters.

lib/Service/BaseUrlReachabilityCheck.php[39-58]
lib/Service/EtherpadHealthCheckService.php[96-123]
lib/Controller/AdminController.php[89-98]
lib/Service/AdminSettingsValidator.php[100-118]
src/admin-settings.js[95-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`BaseUrlReachabilityCheck::check()` makes an HTTP request to a URL derived from admin-submitted settings and sets `nextcloud.allow_local_address=true` while also enabling redirects. This combination creates an SSRF primitive (including via redirects) that defeats Nextcloud’s built-in local-address blocking.
### Issue Context
This request runs in the admin health-check flow and uses the current (unsaved) form values.
### Fix Focus Areas
- lib/Service/BaseUrlReachabilityCheck.php[39-59]
- lib/Service/EtherpadHealthCheckService.php[96-123]
### Suggested fix approach
- Prefer **not** setting `nextcloud => ['allow_local_address' => true]` here; let the default local-address protections apply.
- Consider setting `allow_redirects` to `false` (treat 3xx as “reachable”) or enforce **same-host-only redirects** and re-check each redirect target against local/private ranges.
- If local/internal Etherpad base URLs are a supported deployment, gate local-address access behind an explicit admin opt-in setting and/or validate resolved IPs against private/link-local/loopback ranges before allowing the request.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Rework connection test into per-field checks; unify cookie-domain logic

🐞 Bug fix ✨ Enhancement 🕐 40+ Minutes

Grey Divider

AI Description

• Replaces the single pass/fail connection test with per-field results (API, API key, base URL,
 protected-pads cookie), each rendered at its own form field.
• Adds BaseUrlReachabilityCheck so a broken browser-facing base URL is finally detected (as a
 non-blocking warning), since only the API host was ever contacted before.
• Introduces CookieDomainPolicy/CookieDomainDecision/CookieDomainMessages as the single source
 of truth for the Etherpad session-cookie domain, fixing a bug where the runtime and settings page
 derived different (sometimes incompatible) values.
• Threads a field through AdminHealthCheckException/error mapper so failed checks highlight the
 offending input instead of only appearing in a bottom-of-page message.
• Updates JS/CSS/templates to render per-field check results and a summary that no longer claims
 "successful" when only the request succeeded.
• Corrects README and API docs: the cookie-domain field is no longer "optional", and unrelated
 registrable domains are no longer described as workable via SameSite: "None".
• Adds extensive PHPUnit and Vitest coverage for the new policy, base-URL check, health-check
 service, and settings/controller wiring.
Diagram

graph TD
  UI["Settings Page JS"] --> Controller["AdminController"]
  Controller --> HealthSvc["EtherpadHealthCheckService"]
  Controller --> Policy["CookieDomainPolicy"]
  Controller --> Messages["CookieDomainMessages"]
  HealthSvc --> BaseUrlCheck["BaseUrlReachabilityCheck"]
  HealthSvc --> Policy
  PadSession["PadSessionService"] --> Policy
  AdminSettingsPage["AdminSettings Page"] --> Policy
  Policy --> Decision[("CookieDomainDecision")]
  BaseUrlCheck --> EtherpadHost["Etherpad Base URL"]
  subgraph Legend
    direction LR
    _svc(["Service"]) ~~~ _db[("Data object")] ~~~ _ext{{"External endpoint"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a Public Suffix List library/lookup
  • ➕ More accurate public-suffix detection than the hardcoded heuristic
  • ➕ Avoids missing lesser-known suffixes
  • ➖ Adds a runtime dependency or requires bundling/updating a suffix list
  • ➖ Overkill for a warning-only, non-blocking check
2. Keep per-site derivation but add a shared helper function
  • ➕ Smaller diff, less restructuring
  • ➖ Does not fully prevent future drift since each caller still owns its own decision object
  • ➖ Loses the single CookieDomainDecision value object that unifies status/reason/messages

Recommendation: Consolidating cookie-domain logic into a single CookieDomainPolicy service consumed by all three call sites (runtime, settings page, health check) is the right fix for the drift bug — it removes duplicated derivation code rather than patching each copy separately. The heuristic public-suffix detection (vs. a full Public Suffix List) is a reasonable tradeoff given the app ships no runtime dependencies, and is appropriately surfaced as a 'may be' warning rather than a hard failure.

Files changed (43) +2409 / -336

Enhancement (14) +789 / -55
admin-settings.cssStyle per-field check results and warning status +76/-3

Style per-field check results and warning status

• Adds warning styling to '.ep-status' and new '.ep-check-result' styles (ok tick, warning left-border, skipped) for the per-field verdicts.

css/admin-settings.css

AdminController.phpWire cookie policy/messages into save and health-check responses +88/-11

Wire cookie policy/messages into save and health-check responses

• Adds CookieDomainPolicy/Messages and IURLGenerator dependencies, computes a saved cookie decision on settings save, merges service checks with the protected-pads line, and changes the summary wording from "successful" to a pass/attention message.

lib/Controller/AdminController.php

AdminControllerErrorMapper.phpPropagate the offending field on health-check failures +7/-4

Propagate the offending field on health-check failures

• Includes the field from AdminHealthCheckException in the error DataResponse when present.

lib/Controller/AdminControllerErrorMapper.php

AdminHealthCheckException.phpAdd a field attribute to the health-check exception +16/-0

Add a field attribute to the health-check exception

• Constructor now accepts and exposes an optional form field the failure points at.

lib/Exception/AdminHealthCheckException.php

BaseUrlReachabilityCheck.phpNew check for the browser-facing base URL +108/-0

New check for the browser-facing base URL

• Performs a short GET against the Etherpad base URL (skipped when identical to the API URL), reporting unreachability or HTTP errors as a non-blocking warning since Nextcloud may legitimately be unable to reach it.

lib/Service/BaseUrlReachabilityCheck.php

CookieDomainDecision.phpValue object for a cookie-domain decision +60/-0

Value object for a cookie-domain decision

• Immutable result of CookieDomainPolicy::decide() carrying status, reason, source, hosts and a suggested domain.

lib/Service/CookieDomainDecision.php

CookieDomainMessages.phpTranslates cookie-domain decisions into admin-facing text +103/-0

Translates cookie-domain decisions into admin-facing text

• Builds human-readable messages and HealthCheckItem entries from a CookieDomainDecision, shared by the settings page and health check.

lib/Service/CookieDomainMessages.php

CookieDomainPolicy.phpNew single source of truth for cookie-domain decisions +225/-0

New single source of truth for cookie-domain decisions

• New service that decides the Domain= attribute and OK/warning status given the Nextcloud and Etherpad hosts and an optional configured value; replaces two divergent derivations previously in AdminSettings and PadSessionService.

lib/Service/CookieDomainPolicy.php

HealthCheckItem.phpNew per-check-line value object +34/-0

New per-check-line value object

• Represents one line of the connection test (id, status, label, detail, field) so results can be shown per input rather than as one verdict.

lib/Service/HealthCheckItem.php

HealthCheckResult.phpExtend health check result with cookie decision and checks list +4/-0

Extend health check result with cookie decision and checks list

• Adds nullable CookieDomainDecision and a list of HealthCheckItem to the result payload.

lib/Service/HealthCheckResult.php

StoredAdminSettings.phpAdd cookieDomainConfigured field +2/-0

Add cookieDomainConfigured field

• New readonly bool property recording whether a cookie domain was ever explicitly saved.

lib/Service/StoredAdminSettings.php

ValidatedAdminSettings.phpAdd cookieDomainConfigured field +1/-0

Add cookieDomainConfigured field

• New readonly bool property carried through validation to persistence and the health check.

lib/Service/ValidatedAdminSettings.php

admin-settings.jsRender per-field check results and a separate connection status area +52/-32

Render per-field check results and a separate connection status area

• Adds a dedicated connection-status node and renderConnectionChecks() to show ticks/warnings at each field, updates status classes to include 'warning', and drops the old free-text detail suffix in favor of structured checks.

src/admin-settings.js

admin-settings.phpAdd check-result slots and reorganize connection/diagnostics sections +13/-5

Add check-result slots and reorganize connection/diagnostics sections

• Adds 'data-check-result' spans next to relevant fields, moves the health-check button/status into its own connection section, and updates hint copy.

templates/admin-settings.php

Bug fix (5) +201 / -152
AdminSettingsRepository.phpPersist the actual cookie-domain-configured flag +10/-1

Persist the actual cookie-domain-configured flag

• Stores the validator's cookieDomainConfigured verdict instead of always writing 'yes', and reads it back into StoredAdminSettings.

lib/Service/AdminSettingsRepository.php

AdminSettingsValidator.phpCompute cookieDomainConfigured from payload and stored state +3/-0

Compute cookieDomainConfigured from payload and stored state

• Sets cookieDomainConfigured true when the payload includes the field or it was previously configured.

lib/Service/AdminSettingsValidator.php

EtherpadHealthCheckService.phpRework health check into classified, per-field results +148/-27

Rework health check into classified, per-field results

• Splits the previous single hint-string matcher into classifyFailure/hintForReason/fieldForReason, adds cookie-domain and base-URL checks, and builds a list of HealthCheckItem for API and API-key results.

lib/Service/EtherpadHealthCheckService.php

PadSessionService.phpDelegate cookie-domain resolution to CookieDomainPolicy +13/-45

Delegate cookie-domain resolution to CookieDomainPolicy

• Removes the duplicated host-derivation logic and now resolves the cookie domain via CookieDomainPolicy using the Nextcloud base URL and Etherpad host.

lib/Service/PadSessionService.php

AdminSettings.phpUse CookieDomainPolicy/Messages for settings-page prefill and warning +27/-79

Use CookieDomainPolicy/Messages for settings-page prefill and warning

• Removes the old duplicated derivation logic in favor of CookieDomainPolicy, shows a cookie warning inline when protected pads are enabled, and updates hint/label copy for the cookie domain field.

lib/Settings/AdminSettings.php

Tests (14) +1184 / -90
nextcloud.tsUpdate e2e helper for the new connection status layout +5/-2

Update e2e helper for the new connection status layout

• Points at the new connection-status element and asserts per-field check results instead of summary text.

tests/e2e/fixtures/nextcloud.ts

admin-settings.test.jsAdd JS tests for per-field checks and warning states +216/-17

Add JS tests for per-field checks and warning states

• New/updated Vitest suites covering renderConnectionChecks, the separate connection-status node, field-level error/warning rendering, and stale-result clearing on save/health runs.

tests/js/admin-settings.test.js

bootstrap.phpRegister new OCP stubs +2/-0

Register new OCP stubs

• Adds OCP/Util.php and OCP/Settings/ISettings.php to the stub include list.

tests/phpunit/bootstrap.php

ISettings.phpNew ISettings interface stub +17/-0

New ISettings interface stub

• Adds a minimal stub interface required for unit-testing AdminSettings.

tests/phpunit/stubs/OCP/Settings/ISettings.php

Util.phpNew Util class stub +15/-0

New Util class stub

• Adds a minimal stub for OCP\Util::addStyle/addScript used indirectly by tested code.

tests/phpunit/stubs/OCP/Util.php

AdminControllerErrorMapperTest.phpTest field propagation on health-check exceptions +17/-1

Test field propagation on health-check exceptions

• Verifies the mapper includes/omits the 'field' key based on the exception's field.

tests/phpunit/unit/AdminControllerErrorMapperTest.php

AdminControllerTest.phpTest cookie-decision wiring in save and health-check responses +49/-0

Test cookie-decision wiring in save and health-check responses

• Verifies saved-settings responses include a recomputed protected-pads check and health-check responses aggregate service checks with the controller-appended cookie line and updated summary wording.

tests/phpunit/unit/AdminControllerTest.php

AdminSettingsRepositoryTest.phpTest cookieDomainConfigured persistence +39/-0

Test cookieDomainConfigured persistence

• Adds coverage that an unconfigured cookie domain stays unconfigured and a configured one is persisted as 'yes'.

tests/phpunit/unit/AdminSettingsRepositoryTest.php

AdminSettingsTest.phpNew tests for the settings page's cookie policy integration +91/-0

New tests for the settings page's cookie policy integration

• Covers prefill and warning rendering using the shared CookieDomainPolicy/Messages.

tests/phpunit/unit/AdminSettingsTest.php

BaseUrlReachabilityCheckTest.phpNew tests for BaseUrlReachabilityCheck +151/-0

New tests for BaseUrlReachabilityCheck

• Covers reachable/unreachable/error/skip scenarios and request options for the new base-URL check.

tests/phpunit/unit/BaseUrlReachabilityCheckTest.php

CookieDomainMessagesTest.phpNew tests for CookieDomainMessages +125/-0

New tests for CookieDomainMessages

• Covers message text and HealthCheckItem construction for each decision reason.

tests/phpunit/unit/CookieDomainMessagesTest.php

CookieDomainPolicyTest.phpNew tests for CookieDomainPolicy +259/-0

New tests for CookieDomainPolicy

• Extensive coverage of same-host, shared-parent, unrelated-domain, configured-domain and public-suffix scenarios.

tests/phpunit/unit/CookieDomainPolicyTest.php

EtherpadHealthCheckServiceTest.phpUpdate health-check service tests for classification and per-field checks +174/-60

Update health-check service tests for classification and per-field checks

• Adds coverage for cookie-decision inclusion/exclusion, field attribution on transport failures, and per-line check output.

tests/phpunit/unit/EtherpadHealthCheckServiceTest.php

PadSessionServiceTest.phpUpdate PadSessionService tests for the shared policy +24/-10

Update PadSessionService tests for the shared policy

• Reworks test setup to construct the service with CookieDomainPolicy and a mocked IURLGenerator.

tests/phpunit/unit/PadSessionServiceTest.php

Documentation (8) +233 / -37
README.mdCorrect cookie-domain guidance and add a decision-driven explanation +34/-7

Correct cookie-domain guidance and add a decision-driven explanation

• Replaces same-registrable-domain/SameSite advice with accurate parent-domain requirements, adds worked examples and troubleshooting notes reflecting the new policy.

README.md

api-reference.mdDocument the new checks/protected_pads response fields +37/-0

Document the new checks/protected_pads response fields

• Describes the 'checks' array, 'protected_pads' object, and updated 502 error shape with an optional 'field'.

docs/api-reference.md

de.jsUpdated German translations +27/-5

Updated German translations

• Adds/removes translation strings for the new checks and cookie messages.

l10n/de.js

de.jsonUpdated German translation catalog +27/-5

Updated German translation catalog

• JSON counterpart of the German translation updates.

l10n/de.json

es.jsUpdated Spanish translations +27/-5

Updated Spanish translations

• Adds/removes translation strings for the new checks and cookie messages.

l10n/es.js

es.jsonUpdated Spanish translation catalog +27/-5

Updated Spanish translation catalog

• JSON counterpart of the Spanish translation updates.

l10n/es.json

fr.jsUpdated French translations +27/-5

Updated French translations

• Adds/removes translation strings for the new checks and cookie messages.

l10n/fr.js

fr.jsonUpdated French translation catalog +27/-5

Updated French translation catalog

• JSON counterpart of the French translation updates.

l10n/fr.json

Other (2) +2 / -2
etherpad_nextcloud-admin-settings.mjsRebuilt minified bundle +1/-1

Rebuilt minified bundle

• Regenerated bundle reflecting the admin-settings.js changes.

js/etherpad_nextcloud-admin-settings.mjs

etherpad_nextcloud-admin-settings.mjs.mapUpdated source map +1/-1

Updated source map

• Regenerated source map for the rebuilt bundle.

js/etherpad_nextcloud-admin-settings.mjs.map

@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. SSRF in base URL check ✓ Resolved 🐞 Bug ⛨ Security
Description
BaseUrlReachabilityCheck performs a server-side GET to the configured Etherpad Base URL while
explicitly enabling local-address access and following redirects, bypassing Nextcloud’s usual SSRF
protections. A malicious/compromised admin (or any bug that lets an attacker trigger this admin-only
endpoint with crafted parameters) can make the Nextcloud server probe internal/loopback endpoints.
Code

lib/Service/BaseUrlReachabilityCheck.php[R52-58]

+			$response = $client->request('GET', $trimmed, [
+				'timeout' => self::TIMEOUT_SECONDS,
+				// Etherpad redirects / to /p/... on some setups, and a proxy may
+				// redirect http to https. Following a couple is normal here.
+				'allow_redirects' => ['max' => 3],
+				'nextcloud' => ['allow_local_address' => true],
+			]);
Relevance

●●● Strong

Team has history of accepting URL hardening/validation; SSRF-risk reduction likely welcomed despite
admin-only context.

PR-#13
PR-#34

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new reachability check issues a GET with redirects enabled and allow_local_address explicitly
set, and it is invoked from the admin-triggered health check using the submitted Etherpad Base URL.
The validator enforces URL shape/scheme but does not prevent localhost/private IP targets, and the
browser UI always submits etherpad_host, so the server will attempt to fetch whatever the admin
enters.

lib/Service/BaseUrlReachabilityCheck.php[39-58]
lib/Service/EtherpadHealthCheckService.php[96-123]
lib/Controller/AdminController.php[89-98]
lib/Service/AdminSettingsValidator.php[100-118]
src/admin-settings.js[95-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`BaseUrlReachabilityCheck::check()` makes an HTTP request to a URL derived from admin-submitted settings and sets `nextcloud.allow_local_address=true` while also enabling redirects. This combination creates an SSRF primitive (including via redirects) that defeats Nextcloud’s built-in local-address blocking.

### Issue Context
This request runs in the admin health-check flow and uses the current (unsaved) form values.

### Fix Focus Areas
- lib/Service/BaseUrlReachabilityCheck.php[39-59]
- lib/Service/EtherpadHealthCheckService.php[96-123]

### Suggested fix approach
- Prefer **not** setting `nextcloud => ['allow_local_address' => true]` here; let the default local-address protections apply.
- Consider setting `allow_redirects` to `false` (treat 3xx as “reachable”) or enforce **same-host-only redirects** and re-check each redirect target against local/private ranges.
- If local/internal Etherpad base URLs are a supported deployment, gate local-address access behind an explicit admin opt-in setting and/or validate resolved IPs against private/link-local/loopback ranges before allowing the request.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread lib/Service/BaseUrlReachabilityCheck.php
Jaggob added 5 commits August 1, 2026 17:45
The check contacted an admin-supplied URL with Nextcloud's local-address
protection switched off and followed up to three redirects. The exemption
is the actual bypass; the redirects made it steerable by whoever controls
the configured host, without any admin involvement.

Both are gone. A 3xx now counts as reachable, which is all this line ever
claimed, and the body is streamed rather than buffered so a hostile host
cannot dump data into the request within the timeout.

Dropping the exemption is also the more accurate check: the base URL is
what a browser opens, so an address only this server can reach is broken
regardless. A blocked address says exactly that instead of the generic
"did not answer", which would send the admin hunting a network fault that
does not exist.

The check no longer skips when the base URL matches the API URL. That
shortcut hid the case the strict policy exists for, since the API call may
well succeed against a loopback address no browser can use. Both lines
then land on the same field, so the renderer keeps the more severe verdict
instead of whichever arrived last.
Nextcloud refusing to contact a local address says nothing about whether
users can: an internal or split-DNS deployment serves such addresses to
browsers every day. The line now reports that the check was blocked and
asks the admin to confirm their users can reach it, instead of asserting
that browsers cannot open pads there.

Drops the documented skip path, which no longer exists, and the note about
proxies redirecting http to https — the validator already requires https
for the base URL.
A transport failure can carry a long tail of internal hostnames and
addresses into the admin panel. The base URL check already capped its
detail; the API failure did not. Both use the same limit now, since both
end up in the same place.

The cut happens after classification, not before: a keyword can sit behind
it, and losing it would drop the hint and the field along with it.
"saved or not" read as an afterthought. That the test works on the entered
values follows from "the values above", so the clause was carrying its
weight in awkwardness rather than information.
The English source read as a chain of clauses, and the translations
followed it word for word – "the cookie follows" became "das Cookie folgt",
"la cookie funcionará", "le cookie suivra", none of which is how any of
them would be put. Simplifying the source removed the cause rather than
patching each translation: it now names the subject, says plainly that the
browser cannot send the cookie, and lists the two ways out as separate
sentences.

Spanish used "dominio principal" for parent domain, which reads as "main
domain"; it is "dominio padre común" now, in the message and in the field
hint. Also picks up smaller wording fixes: "mit den oben eingetragenen
Werten", "con los valores indicados arriba", "hébergez les deux sur le
même hôte".
@Jaggob
Jaggob merged commit 3f5c6c2 into main Aug 1, 2026
13 checks passed
@Jaggob
Jaggob deleted the feat/protected-pads-cookie-check branch August 1, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Health check: verify that protected pads can actually work

1 participant