fix: harden calendar public sendmail against phishing (OC10-123)#1362
fix: harden calendar public sendmail against phishing (OC10-123)#1362DeepDiver1975 wants to merge 3 commits into
Conversation
The public sendmail endpoint (POST /v1/public/sendmail, EmailController::sendEmailPublicLink) let any authenticated user send a mail from the server identity with attacker-controlled recipient, link and calendar name. Two problems allowed weaponizing it for phishing: - The calendar name was concatenated raw into a print_unescaped() block in mail.publication.html.php, so arbitrary HTML (e.g. a phishing anchor) landed unescaped in the mail body. The bold formatting is now resolved on the translator-controlled string first and the user- supplied name/username are HTML-escaped before interpolation. - The link (url) was accepted verbatim, so the button could point anywhere. It is now validated to be an absolute http(s) URL below this instance's calendar app base and matching a public sharing route (p/embed/public + token); anything else is rejected with 400. Adds regression tests covering the escaping and URL validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
eed5f49 to
b1c3080
Compare
| // non-empty token, mirroring appinfo/routes.php: | ||
| // p/{token}, p/{token}/{fancyName}, embed/{token}, public/{token} | ||
| $path = \substr($url, \strlen($appBase)); | ||
| return (bool)\preg_match('~^(p|embed|public)/[^/?#]+~', $path); |
There was a problem hiding this comment.
Do we need to consider something like public/../token?
…n sendmail The public-link validation compared the client URL against the app base from linkToRouteAbsolute(). On instances with an active front controller that base omits the "/index.php/" segment while the client always includes it (OC.linkTo), so every legitimate link was rejected with 400. Normalise the "/index.php/" segment away on both sides before matching. Also reject any ".." traversal segment in the path so a validated public route cannot be normalised by the client into a different location under the same host, and return 401 when there is no session user instead of dereferencing null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Code review + fixes appliedReviewed the phishing-hardening changes. The escaping fix and overall approach are correct; I verified I found two issues and pushed a fix (e775f09): 🔴 URL validation rejected legitimate links under an active front controller
The original unit test masked this because it stubbed 🟡 Path-traversal within the same hostThe prefix + 🟡 Null user
VerificationFull calendar unit suite green locally: 65 tests, 366 assertions (was 63; +2 new tests). Nothing else blocking. 👍 |
A deeper review found the "." traversal guard only matched slash-delimited
segments, so a backslash payload (browsers normalise "\" to "/" client-side)
or a percent-encoded ".." slipped through, and the route check was a prefix
match that left a trailing path, query or fragment unvalidated. A crafted
"valid" link could therefore resolve to an arbitrary same-origin path,
defeating the intent of the fix.
- Reject any URL containing a backslash outright.
- rawurldecode() the path once before re-checking for ".." so %2e%2e / ..%2f
encoded traversal is caught.
- Fully anchor the public-route grammar (p/{token}[/{fancyName}],
embed/{token}, public/{token}) so no trailing path/query/fragment is allowed.
Tests: harden the HTML-escaping test with a positive-render assertion; assert
embed/ and public/ routes are accepted; cover the null-user 401 branch; add
display-name injection; and extend the rejected-URL provider with host-confusion,
empty-token, backslash/encoded traversal and query/fragment cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Follow-up: deeper security + test review (eeb29ea)Ran a dedicated offensive-security pass and a test-coverage pass over the current state. Both confirmed the HTML escaping and host-pinning are robust (userinfo 🟠 [MEDIUM] Residual traversal bypass in
|
|
Superseded by #1363, which fixes the phishing link vector at the trust boundary rather than by validating an attacker-supplied URL. Instead of accepting a verbatim Suggest closing this in favour of #1363. |
Summary
Hardens the public calendar "send mail" endpoint (
POST /v1/public/sendmail,EmailController::sendEmailPublicLink) against being abused to send phishingmail from the ownCloud server identity. Ref: OC10-123.
Two independent issues let a low-privileged authenticated user craft a
convincing phishing mail (attacker-controlled recipient, link target and
injected body content), sent from the instance's configured
Fromaddress:HTML injection in the mail body — the calendar name was concatenated
raw into a
print_unescaped()block inmail.publication.html.php, somarkup supplied via the
nameparameter (e.g. an<a href>phishing link)was emitted unescaped. The bold formatting is now resolved on the
translator-controlled template string first, and the user-supplied
name/usernameare passed through\OCP\Util::sanitizeHTML()beforeinterpolation. The intended bold styling is preserved.
Unvalidated link target — the
urlparameter was accepted verbatim, sothe "Click here to access it" button could point anywhere.
urlis nowrequired to be an absolute
http(s)URL located below this instance'scalendar app base and matching a public-sharing route (
p/embed/publicappinfo/routes.php. Anything else is rejected with400 Bad Requestand no mail is sent.Tests
tests/unit/controller/emailcontrollerTest.php:nameis escaped in the rendered body;javascript:, wrong app and non-public routesare all rejected (data provider).
Full calendar unit suite passes locally (63 tests).
Notes / follow-up
This closes the phishing/HTML-injection vector reported. A residual,
lower-severity behaviour remains: an authenticated user can still email a link
to a legitimately public calendar to an arbitrary recipient. Fully binding
the recipient/link to a calendar the caller actually published would require a
new public (OCP) API in core to resolve a public token to its owner (the
token→owner mapping lives in the
davapp's privateCalDavBackend), which isout of scope for this app-level fix.
🤖 Generated with Claude Code