cd82ad4d51 fix(caldav): encode task uids in hrefs instead of interpolating them raw (#3560)
Task uids are client chosen — the api only generates a uuid when the
field is empty (`pkg/models/tasks.go`), and the CalDAV parser stores
whatever the inbound VTODO carried (`pkg/caldav/parsing.go`). That value
went into hrefs verbatim, which lets a uid forge a path or inject
markup.

Split out of #3551 so the path-forgery half gets its own review.

## What goes wrong today

**Path forgery.** A uid containing a slash makes the href appear to live
in another collection:

```
UID:evil/../../../projects/5/y  →  <D:href>/dav/projects/36/evil/../../../projects/5/y.ics</D:href>
                                    path.Clean → /dav/projects/5/y.ics
```

Anyone who can create a task in a project the victim can see can plant
one; it then renders inside the victim's collection listing pointing
elsewhere. Same class as GHSA-48ch-p4gq-x46x.

**XML injection.** `sync_collection.go` wraps hrefs in `xmlEscape`, but
PROPFIND and calendar-multiget hand `Resource.Path` to caldav-go's
`ixml.HrefTag` → `ixml.Tag`, a plain `Sprintf`. `ixml.EscapeText` sits
in the same file and is only used for prop content. A uid with `<`, `>`
or `&` therefore lands raw in the multistatus body.

Input validation cannot close either: RFC 5545 §3.3.11 puts `<`
(`%x3C`), `>` (`%x3E`) and `&` (`%x26`) all inside `TSAFE-CHAR`, so they
are legal in a TEXT value.

## The fix

Percent-encode the uid to RFC 3986 `unreserved` when building an href.
`url.PathEscape` is not enough — it leaves sub-delims alone, so `&`
survives:

```
url.PathEscape("a&b<c>d")  →  "a&b%3Cc%3Ed"
```

Encoding conservatively means no XML metacharacter reaches the document,
so no change to the vendored library is needed. Existing uuid uids are
entirely `unreserved`, so their hrefs are byte-for-byte unchanged and no
client resyncs.

Then decode on the way back in — neither side did:

- echo v5 hands back the still-encoded path segment (`c.Param("task")` →
`evil%2F..%2F%3Cx%3E`), so `TaskHandler` needed the decode.
- `GetResourcesByList` parses hrefs out of the REPORT body and never
decoded, so encoded hrefs the server itself emitted would silently stop
matching and tasks would vanish from multiget responses.

One more, found while checking the other call sites: caldav-go builds
`Resource.Path` from `request.URL.Path`, which Go has already decoded,
and writes it into XML unescaped. So a PROPFIND against the encoded href
echoed the forged form right back:

```
PROPFIND /dav/projects/36/evil%2F..%2F..%2F..%2Fprojects%2F5%2Fpwned%3Cx%3E%26y.ics
  →  <D:href>/dav/projects/5/pwned<x>&y.ics</D:href>
```

Task requests now carry a canonical href that the storage returns
instead of echoing the client's path.

## Not fixed here

Principal hrefs interpolate the username unescaped, and
`pkg/user/user_create.go` only rejects spaces and the link-share
pattern. `isOwnPrincipalPath` limits this to the authenticated user's
own username, so it never crosses a user boundary — separate concern,
not uid-related.

## How to verify

1. Create a task over CalDAV whose VTODO carries
`UID:evil/../../../projects/<other project id>/pwned<x>&y` in a project
you own.
2. Run `curl -u <user>:<caldav-token> -X PROPFIND -H 'Depth: 1'
https://<instance>/dav/projects/<that project id>/`
3. **Expected:** the `<D:href>` for that task is percent-encoded, stays
under `/dav/projects/<that project id>/`, and the response body parses
as XML.
**Before this PR:** the href resolves to the other project's collection
and the body is malformed XML.

1. `GET` that percent-encoded href.
2. **Expected:** 200 with the task's VTODO — the uid round-trips.

1. Send a `calendar-multiget` REPORT listing that same href.
2. **Expected:** 207 containing the task.

1. Take any pre-existing task with a normal uuid uid and PROPFIND its
collection.
2. **Expected:** its href is unchanged from before this PR — encoding is
a no-op for uuids, so no client is forced to resync.

---------

Co-authored-by: kolaente <k@knt.li>
2026-08-19 20:13:06 +00:00
2026-07-19 12:58:41 +02:00
2026-08-04 00:24:53 +02:00
2026-08-04 13:42:43 +02:00

Build Status License: AGPL-3.0-or-later Install Docker Pulls OpenAPI Docs

Vikunja

The task manager you actually own.

If Vikunja is useful to you, please consider supporting the project. You can buy a coffee, sponsor on GitHub or buy a sticker pack. We're also offering a hosted version of Vikunja if you want a hassle-free solution for yourself or your team. If you or your company needs admin panel, audit logs or time tracking, check out Vikunja Pro.

Note

For the development of Vikunja, we're using LLM-Assisted coding tools in various parts of the codebase. Most contributions made @tink-bot are built that way.

Table of contents

Security Reports

If you find any security-related issues you don't want to disclose publicly, please use the contact information on our website.

Features

See the features page on our website for a more exhaustive list or try it on try.vikunja.io!

Docs

All docs can be found on the Vikunja home page.

Roadmap

See the roadmap (hosted on Vikunja!) for more!

Contributing

Please check out the contribution guidelines on the website.

License

Most of this repository is licensed under AGPL3.0orlater. The contents of desktop/ are licensed under GPL3.0orlater.

Unsplash Images

Background images from Unsplash are distributed under the Unsplash License. The license requires giving credit to the photographer and Unsplash. See Unsplashs terms for more information.

S
Description
No description provided
Readme AGPL-3.0
1.3 GiB
Latest
2026-08-04 06:42:44 -05:00
Languages
Go 50.5%
JavaScript 30.3%
TypeScript 10%
Vue 8.5%
SCSS 0.3%
Other 0.2%