fix: harden calendar public sendmail against phishing via server-side link generation (OC10-123)#1363
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>
…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>
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>
The public "send mail" endpoint accepted the link target as a verbatim
`url` parameter and then tried to prove, by string inspection, that it
pointed at a public calendar route on this instance. That validator
needed repeated bypass patches (backslash normalisation, percent-decoded
traversal, anchored route regex) and remained fragile against
client-side URL normalisation — the wrong trust boundary.
Change the API contract so the caller supplies only the public sharing
`token`. `EmailController` builds the absolute link itself via
`IURLGenerator::linkToRouteAbsolute('calendar.view.public_index_with_branding')`,
which is same-origin and a genuine public sharing route by construction
and URL-encodes the token. The whole `isValidPublicCalendarUrl()`
inspection logic is removed; an empty/missing token yields 400 and no
mail is sent.
The client (mailerservice / calendarlistcontroller) now sends
`calendar.publicToken` instead of the pre-built `publicSharingURL`.
The HTML-escaping of the calendar/user name in the mail body from the
prior fix is retained.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
|
Do we need any protection for the token? I'm not sure if I could send an almost-random token and get a valid link for the calendar of another user. Maybe it isn't a big problem because the calendar would be either public (so anyone can access) or be protected via access. Maybe creating the token like In the server, we use the user in the session to verify the token. The original token can be inferred because at least the hash has a fixed length. If the server creates the same "newtoken" from the original inferred token and the session user, then we can proceed to send the email. |
|
Good question — I dug into what the token actually is before deciding, and I don't think it needs extra protection in this PR:
On the So I'd keep this PR focused on the attacker-controlled-URL phishing vector (fixed at the trust boundary) and file a follow-up to properly bind the recipient/link to a calendar the caller actually published, via a new OCP API to resolve a public token to its owner. WDYT? |
Summary
Supersedes #1362. Hardens the public calendar "send mail" endpoint
(
POST /v1/public/sendmail,EmailController::sendEmailPublicLink) againstbeing abused to send phishing mail from the ownCloud server identity — but
fixes the phishing link vector at the trust boundary instead of by
inspecting an attacker-supplied URL.
Why this instead of URL validation
#1362 accepted the link target as a verbatim
urlparameter and then tried toprove, by string inspection, that it pointed at a public calendar route on this
instance. That validator needed three rounds of bypass patches (backslash
normalisation, percent-decoded traversal, an anchored route regex) and remained
inherently fragile against client-side URL normalisation. Validating untrusted
input by inspection is the wrong design here.
The only input actually needed to build the link is the public sharing
token — everything else the client sent was derived from it. So the API
contract changes from
{to, url, name}to{to, token, name}:EmailControllerbuilds the absolute link itself viaIURLGenerator::linkToRouteAbsolute('calendar.view.public_index_with_branding', ['token' => $token]).The result is same-origin and a genuine public sharing route by
construction, and the token is URL-encoded by the generator — no injection
or traversal surface remains.
isValidPublicCalendarUrl()inspection logic is deleted(~50 lines). An empty/missing token yields
400 Bad Requestand no mail.mailerservice.js/calendarlistcontroller.js) now sendscalendar.publicTokeninstead of the pre-builtpublicSharingURL.The HTML-escaping of the calendar name / user display name in the mail body
(the other half of #1362) is retained — that fix is still correct and
unrelated to the link vector.
Tests
tests/unit/controller/emailcontrollerTest.php:exactly that link is sent;
nulltoken is rejected with400and no mail is sent;nameand in the user display name is escaped in therendered body (retained from fix: harden calendar public sendmail against phishing (OC10-123) #1362).
Full calendar unit suite passes locally (62 tests, 355 assertions).
Notes / follow-up
The residual, lower-severity behaviour flagged in #1362 is unchanged: an
authenticated user can still email a link to a legitimately public calendar
to an arbitrary recipient, because the token is still caller-supplied. 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), whichis out of scope for this app-level fix.
🤖 Generated with Claude Code