Skip to content

fix: harden calendar public sendmail against phishing via server-side link generation (OC10-123)#1363

Merged
DeepDiver1975 merged 4 commits into
masterfrom
fix/oc10-123-sendmail-token-contract
Jul 15, 2026
Merged

fix: harden calendar public sendmail against phishing via server-side link generation (OC10-123)#1363
DeepDiver1975 merged 4 commits into
masterfrom
fix/oc10-123-sendmail-token-contract

Conversation

@DeepDiver1975

Copy link
Copy Markdown
Member

Summary

Supersedes #1362. Hardens the public calendar "send mail" endpoint
(POST /v1/public/sendmail, EmailController::sendEmailPublicLink) against
being 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 url parameter and then tried to
prove, 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}:

  • EmailController builds the absolute link itself via
    IURLGenerator::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.
  • The entire isValidPublicCalendarUrl() inspection logic is deleted
    (~50 lines). An empty/missing token yields 400 Bad Request and no mail.
  • The client (mailerservice.js / calendarlistcontroller.js) now sends
    calendar.publicToken instead of the pre-built publicSharingURL.

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:

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 dav app's private CalDavBackend), which
is out of scope for this app-level fix.

🤖 Generated with Claude Code

DeepDiver1975 and others added 4 commits July 14, 2026 14:23
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>
@jvillafanez

Copy link
Copy Markdown
Member

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 <token><hash_data> where the hash_data is the hash of the token + user might help

newtoken = token + hash(token + user)

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.
It isn't perfect, but it should be easy to implement, and it should provide some protection.

@DeepDiver1975

Copy link
Copy Markdown
Member Author

Good question — I dug into what the token actually is before deciding, and I don't think it needs extra protection in this PR:

  • The publish token is a secure-random 16-char [A-Z0-9] value (~82 bits, generated via ISecureRandom in CalDavBackend::setPublishStatus() in the dav app), and the /p/{token} route (view#public_index_with_branding) is a @PublicPage. So the token is the access capability: anyone holding it can already view the calendar unauthenticated.
  • The caller must already possess the token to send it (the client only ever derives it from the calendar's own publish-url), so this endpoint can't disclose a calendar that wasn't already reachable by that token. Guessing a valid token is a ~2^82 brute force — infeasible.

On the newtoken = token + hash(token + user) idea: it binds to the session user (the sender), not the calendar owner, so it wouldn't actually stop B from emailing A's public link — the server would just recompute the same hash for B's session with any token. To actually bind sender→owner, the composite would have to be minted by the owner at publish time, which changes the token format and breaks every already-published URL. That's exactly the cross-app work (the token→owner mapping lives in dav's private CalDavBackend) I flagged as out-of-scope in the Notes/follow-up section. I'd also rather not layer a home-grown HMAC over a value that's already a strong bearer secret.

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?

@DeepDiver1975 DeepDiver1975 merged commit db65a9a into master Jul 15, 2026
10 checks passed
@DeepDiver1975 DeepDiver1975 deleted the fix/oc10-123-sendmail-token-contract branch July 15, 2026 16:34
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.

2 participants