Commit Graph

13559 Commits

Author SHA1 Message Date
kolaente 75629158cb test(user): cover TOTP lockout persistence and password-reset unlock
Verifies that HandleFailedTOTPAuth locks the account after 10 rolled-back
caller sessions (the regression from GHSA-fgfv-pv97-6cmj), and that the
persisted password reset token can unlock the account via ResetPassword.
2026-04-09 16:08:26 +00:00
kolaente d435c50df3 fix(security): persist TOTP lockout across login rollback
The failed-TOTP handler shared the login request's xorm session, and the
login handler rolled that session back after a failed login. The status
change to StatusAccountLocked was silently discarded, so the account was
never locked regardless of how many failed TOTP attempts arrived.

HandleFailedTOTPAuth now opens its own session and commits independently
of the caller. The login handler rolls back its session before invoking
the handler so the lockout write can acquire a write lock on SQLite
shared-cache.

Also handles the Redis keyvalue backend returning the attempt counter as
a string instead of int64, which would have prevented the lockout path
from ever running on Redis.

See GHSA-fgfv-pv97-6cmj.
2026-04-09 16:08:26 +00:00
kolaente 6df0d6c8f5 feat(tasks): cap repeat_after at 10 years to harden repeating-task handler
Add MaxTaskRepeatAfterSeconds (10 years in seconds) and reject any
create/update that tries to store a value outside [0, cap] with a new
ErrInvalidTaskRepeatInterval (error code 4029). Defense-in-depth
alongside the arithmetic fix in addRepeatIntervalToTime: keeps stored
values well away from int64 overflow and bounds the range of inputs
a future refactor could trip over.
2026-04-09 16:07:48 +00:00
kolaente 3c3d4b863d test(tasks): add DoS regression test for ancient repeating due dates
Exercises updateDone end-to-end with a 1900-01-01 due date, 1-second
interval, and asserts the call completes in well under a second.
Catches any regression that reintroduces the O(n) loop in
addRepeatIntervalToTime (GHSA-r4fg-73rc-hhh7).
2026-04-09 16:07:48 +00:00
kolaente 9dc3d7eb4f fix(tasks): replace O(n) loop in repeating-task handler with arithmetic
addRepeatIntervalToTime used to advance t by whole intervals via an
unbounded loop. A repeating task with an ancient due_date and a
one-second interval required billions of iterations per task update,
turning completion of such a task into a trivial denial-of-service
(GHSA-r4fg-73rc-hhh7). Compute the number of intervals directly, with
guards for zero/negative durations, saturated time.Sub, and int64
overflow.

Covered by TestAddRepeatIntervalToTime, including the 1900-01-01 PoC
case.
2026-04-09 16:07:48 +00:00
kolaente 879462d717 fix(caldav): enforce URL project match in GetResourcesByList
Multiget REPORT requests would happily return tasks from projects
different from the one in the href, even though GetTasksByUIDs now
filters by access. Drop any returned task whose real project_id does
not match the project ID parsed from the href path segment.

Hardening for GHSA-48ch-p4gq-x46x.
2026-04-09 16:07:32 +00:00
kolaente 200b787c16 fix(caldav): reject GetResource when URL project mismatches task project
Even with the GetTasksByUIDs authz filter in place, a user with access
to multiple projects could read a task from project B by requesting it
under project A's URL. Enforce that the task's real project_id matches
the project ID parsed from the CalDAV URL path and 404 otherwise.

Adjusts the Delete Subtask test to use the correct URL project for
uid-caldav-test-child-task-2 (which lives in project 38, not 36);
the previous URL only worked because of the authz gap being closed.

Hardening for GHSA-48ch-p4gq-x46x.
2026-04-09 16:07:32 +00:00
kolaente f1e12c6f64 fix(caldav): enforce task read authorization on GetTasksByUIDs
Previously GetTasksByUIDs returned any task matching the UID regardless
of the caller's access, letting any authenticated CalDAV user read any
task by guessing or knowing a UID. Filter by accessible project IDs at
the SQL level using the existing accessibleProjectIDsSubquery helper.

Fixes GHSA-48ch-p4gq-x46x.
2026-04-09 16:07:32 +00:00
kolaente 0f3730d045 fix(notifications): escape markdown in user-controlled strings in email lines
Task titles, project titles, team names, doer/assignee names, and API
token titles were interpolated raw into Line(...) calls whose content is
rendered to HTML by goldmark and then sanitized with bluemonday UGCPolicy.
UGCPolicy intentionally allows safe <a href> and <img src> with
http/https URLs, so a title containing Markdown link or image syntax
would survive sanitization as a working phishing link or tracking pixel
in a legitimate Vikunja email.

Introduce notifications.EscapeMarkdown, which prefixes every CommonMark
§2.4 backslash-escapable ASCII punctuation character — including '<' so
autolinks like `<https://evil.com>` are neutralized before reaching
goldmark — with a backslash. Apply it to every user-controlled argument
of every Line(...) call in pkg/models that feeds into an i18n template,
and to the hand-built "* [title](url) (project)" Markdown link in the
overdue-tasks digest notification.

Also escape the migration error string in MigrationFailedNotification,
an additional sink not listed in the advisory (error messages can carry
user-controlled content from the external migration source).

Subject(...), Greeting(...), and CreateConversationalHeader(...) are
left unchanged: Subject is passed directly to the mail library and is
not markdown-rendered, Greeting is rendered via html/template's built-in
HTML escaping without markdown, and the conversational header is
sanitized as raw HTML by bluemonday in mail_render.go.

Fixes GHSA-45q4-x4r9-8fqj.
2026-04-09 15:44:04 +00:00
kolaente aa2b8c43f1 fix(caldav): escape user-controlled strings per RFC 5545 in VCALENDAR output
Task titles, UIDs, descriptions, categories, organizer usernames, alarm
descriptions, relation UIDs, and the calendar name were concatenated raw
into the VCALENDAR text. A task title containing CR/LF could plant new
iCalendar properties (ATTACH, X-INJECTED, VALARM, etc.) that CalDAV
clients would parse as legitimate calendar data.

Introduce escapeICalText, which escapes backslash, CR/LF, semicolon, and
comma per RFC 5545 §3.3.11, and apply it at every sink in ParseTodos,
ParseAlarms, and ParseRelations. Each Category is escaped individually;
the comma that joins categories is the literal list delimiter and stays
unescaped. The now-redundant regexp-based LF handling in the DESCRIPTION
branch is removed.

getCaldavColor is hardened at the same output boundary: non-hex
characters are stripped before interpolation so CR/LF in a crafted color
string cannot inject new iCal property lines, closing a gap where
upstream HexColor validation only bounds length and does not reject
control characters.

Fixes GHSA-2g7h-7rqr-9p4r.
2026-04-09 15:44:04 +00:00
kolaente fc216c38af fix(labels): derive label max permission from accessible tasks only
The previous hasAccessToLabel implementation ran `Get(ll)` against a
label_tasks LEFT JOIN with no ORDER BY, which meant the database was
free to pick any matching row. When a label had multiple attachments,
or when access was granted via the creator branch while the label also
had label_tasks rows pointing at inaccessible tasks, the picked row
could belong to a task the caller could not actually read.

That led to two concrete problems reported on the follow-up review of
GHSA-hj5c-mhh2-g7jq:

  1. maxPermission (exposed as the x-max-permission response header)
     could be derived from a task the caller has no access to, ending
     up as 0 or lower than the caller's real best permission on the
     label.
  2. Task.CanRead on a dangling/inaccessible task could return an
     error and surface as a 500, even though the label itself was
     perfectly readable via the creator branch.

Split the logic instead:

  * Use `Exist` for the boolean access check, using the same carefully
    grouped `And(Eq{labels.id}, Or(accessibleTask, creator))` cond.
  * Compute maxPermission by selecting the label_tasks rows whose
    task lives in a project the caller can access, then iterating
    those tasks with `Task.CanRead` and taking the maximum.
  * Fall back to PermissionRead when the access was granted via the
    creator branch and no accessible task attachment exists.
2026-04-09 15:43:04 +00:00
kolaente e836555032 fix(labels): correct broken access-control query for label reads (GHSA-hj5c-mhh2-g7jq)
hasAccessToLabel built its WHERE clause by chaining xorm session .Where,
.Or, and .And calls. xorm flattened those to `WHERE A OR B OR C AND D`,
which under SQL precedence evaluates as
`A OR B OR (C AND D)` — so the `labels.id = ?` predicate only narrowed
the project-access branch. The standalone
`label_tasks.label_id IS NOT NULL` branch leaked every label with any
label_tasks row to any authenticated user, and the
`labels.created_by_id = ?` branch leaked any label the caller had ever
created regardless of the requested id.

Rewrite the query using explicit builder.And / builder.Or grouping so
the label-id scope wraps the entire disjunction, drop the bogus
label_tasks-is-not-null branch, and keep the creator branch only for
real user auths. Replace Exist(ll) with Get(ll) so the resulting
LabelTask row is populated and the follow-up Task.CanRead check that
computes maxPermission actually runs; fall back to PermissionRead when
the match came via the creator branch and no task row is joined.
2026-04-09 15:43:04 +00:00
kolaente 379d8a5c19 test(security): webtest that a deleted link share rejects its still-valid JWT
End-to-end regression test for GHSA-96q5-xm3p-7m84 / CVE-2026-35594: mints
a JWT for a link share via the real helper, then deletes the share row and
invokes the real ReadAllWeb handler to prove the full request path (not
just the unit-tested GetLinkShareFromClaims) surfaces the revocation.

Also fixes a pre-existing stale literal in the TestLinkSharing test fixture
struct: linkshareRead declared Hash="test1" while the actual fixture row
id=1 uses Hash="test". The old code never looked at the DB so the mismatch
went unnoticed; after the fix it would cause every link-share webtest that
used linkshareRead to fail hash validation.
2026-04-09 15:38:07 +00:00
kolaente e025209e3c fix(security): validate link share JWTs against DB on every request
Previously GetLinkShareFromClaims built a *LinkSharing entirely from JWT
claims with no DB interaction, so deleted shares and permission downgrades
took up to 72h (the JWT TTL) to take effect. The permission and sharedByID
claims were trusted blindly.

GetLinkShareFromClaims now takes an *xorm.Session, looks up the share via
GetLinkShareByID, verifies the hash claim against the DB row, and returns
ErrLinkShareTokenInvalid when the row is missing or the hash mismatches.
The permission and sharedByID claims are discarded; the DB row is
authoritative. GetAuthFromClaims opens a read session for the link-share
branch, mirroring the existing API-token branch.

Token creation and the JWT format are unchanged, so already-issued tokens
keep working except when the underlying share has been deleted or its hash
no longer matches.

Fixes GHSA-96q5-xm3p-7m84 / CVE-2026-35594.
2026-04-09 15:38:07 +00:00
kolaente 6a0f39b252 fix(security): enforce HTTP method and path in scoped API token matcher
CanDoAPIRoute's non-CRUD fallback branch compared a path-derived
permission name to the token's permission strings without checking
the request method. A token with projects.background (registered for
GET /projects/:project/background) could therefore invoke DELETE on
the same path. The same method-confusion affected the whole
/projects/:project/views/:view/buckets[/:bucket] cluster, where a
token with projects.views_buckets (registered for GET) authorized
PUT, POST, and DELETE on any accessible view's buckets.

The matcher also leaked CRUD permissions between parent and nested
sub-resource groups. When the request targeted a nested CRUD resource
(e.g. projects_teams, projects_shares, projects_users, projects_views,
projects_webhooks, projects_views_tasks, tasks_assignees, tasks_labels,
tasks_comments, tasks_relations, tasks_attachments, teams_members),
the matcher fell back from the specific group to the parent's permission
list but then looked the permission name up inside the sub-resource's
RouteDetail map. The effect was that a token holding only projects.read_all
also authorized GET on every nested projects_* list endpoint, and the
same held for create/update/delete and for the tasks.* family.

Rewrite the matcher to iterate the token's own permissions and accept
only when the stored RouteDetail's (Path, Method) matches the request.
This removes all the path-derived group guessing and makes the stored
detail the single source of truth. Preserve the tasks.read_all quirk
(one permission, two list endpoints) as an explicit two-path allowlist
inside the loop.

Extract a GetAPITokenRoutes accessor so the new property-based webtest
can consume the same snapshot served by GET /api/v1/routes.

Add TestAPITokenMethodMatching in pkg/webtests: using the live echo
router and the live apiTokenRoutes map, it iterates every advertised
permission against every registered route and asserts the matcher
accepts iff the stored (Path, Method) matches. Any future collision
introduced by a new non-CRUD route on a shared path will be caught.

After this change, previously-dead permissions like
projects.background_delete, projects.views_buckets_{put,post,delete},
other.avatar, other.ws and caldav.access start working as their UI
labels imply. Tokens that relied on the over-broad background /
views_buckets grants, or on cross-cluster CRUD bleed-through, will
lose the extra access - that is the fix.

Refs: GHSA-v479-vf79-mg83
2026-04-09 15:17:20 +00:00
kolaente 27a88dd17a
fix(deps): bump basic-ftp override to 5.2.1 to patch CRLF injection
Resolves Dependabot alert #183 (high severity): basic-ftp 5.2.0 is
vulnerable to FTP command injection via CRLF. The package is pulled in
as a dev-only transitive dependency by @histoire/plugin-screenshot.
2026-04-09 15:34:00 +02:00
kolaente 8814cb37d8
fix(tasks): vertically center checkbox in project task row
The tooltip span wrapping the checkbox used the inherited line-height
(~24px), so the 18px inline-block checkbox sat on the baseline and
appeared misaligned with the task text. Making the span an inline-flex
container collapses it to the checkbox size and centers it properly.
2026-04-09 15:30:27 +02:00
kolaente 88b534776a fix(kanban): skip upsert when repeating task already in default bucket (#2573)
When a repeating task dropped on the done bucket is already in the
view's default bucket, the upsert would try to UPDATE with an
unchanged bucket_id. MySQL reports 0 affected rows for unchanged
updates, so upsert fell through to INSERT and hit the unique
constraint on (task_id, project_view_id).
2026-04-09 10:45:34 +00:00
kolaente 1e784aa194 fix(tasks): route repeating tasks to default bucket when marked done (#2573) 2026-04-09 10:45:34 +00:00
kolaente 007379cff1 refactor(tasks): add moveTaskToDefaultBuckets helper (#2573) 2026-04-09 10:45:34 +00:00
kolaente 9a8126c111 test(tasks): add failing test for repeating task bucket routing via Task.Update (#2573) 2026-04-09 10:45:34 +00:00
kolaente 3d7bab4497 fix(kanban): route repeating tasks to default bucket when dropped on done (#2573) 2026-04-09 10:45:34 +00:00
kolaente e37b54abca test(kanban): add failing test for repeating task bucket routing on done (#2573) 2026-04-09 10:45:34 +00:00
kolaente a574d623b1 test: add e2e regression test for link share loop while logged in
Covers #2546: a logged-in user navigating to a public link share URL
used to bounce infinitely between /share/:hash/auth and the project
view, stranding the user on an empty NoAuthWrapper shell. Two distinct
issues in checkAuth() produced the same symptom:

  1. The 1-minute debounce skipped re-parsing the new link share JWT
     when the user was already authenticated.
  2. The "same user, skip setUser" fast path compared only `id`, so a
     logged-in user whose id collided with the link share's id kept
     the USER `info.value.type` and `authLinkShare` never flipped.

The test pins both the logged-in user and the link share to the same
numeric id so it exercises the collision path, which catches both
regressions at once.
2026-04-09 10:20:46 +00:00
kolaente 432c5f2817 fix: include type in checkAuth's same-user skip check
Users and link shares share the same numeric id space in JWTs. When a
logged-in user opened a link share whose id happened to match their own
user id, checkAuth() would see `info.value.id === jwtUser.id` and skip
`setUser()`, leaving `info.value.type` as USER even though the new token
was a LINK_SHARE. As a result, `authLinkShare` never flipped to true and
the router guard bounced between /share/:hash/auth and the project view,
stranding the user on an empty NoAuthWrapper shell.

Compare on type as well so USER→LINK_SHARE transitions always replace
the user object.

Refs #2546
2026-04-09 10:20:46 +00:00
kolaente 2000732e35 fix: skip refreshUserInfo for link share tokens to prevent logout loop 2026-04-09 10:20:46 +00:00
kolaente 1d3a234b05 fix: reset checkAuth debounce in linkShareAuth to prevent redirect loop
When a logged-in user opens a public link share, the 1-minute debounce
on checkAuth() caused it to skip re-parsing the new link share JWT.
This left authLinkShare as false, triggering an infinite redirect loop
in the router guard.

Fixes #2546
2026-04-09 10:20:46 +00:00
kolaente 91728c0273 test: wire up API URL for anonymous link share e2e tests
The anonymous link share tests don't use the authenticatedPage fixture
(which implicitly calls setupApiUrl via login()), so the browser was
falling back to the default `window.API_URL = '/api/v1'` baked into
index.html. That relative path resolved against the preview server port
and never reached the API, causing the tests to hang on the NoAuth
"Welcome Back" page waiting for elements that never rendered.

Adding a beforeEach that calls setupApiUrl() restores these tests.
2026-04-09 10:20:46 +00:00
kolaente 457ffbfe51 test(webhook): assert bad webhook is retried in no-duplicate test
Adds a hit counter to the bad webhook and asserts it is attempted at
least 3 times, proving the watermill retry middleware actually fires
on a failing delivery. We use GreaterOrEqual rather than an exact
count because gochannel resends nacked messages, so a permanently
failing delivery keeps running through retry cycles until the test
times out its wait window.
2026-04-09 09:26:04 +00:00
kolaente ca7f82a5e5 fix(webhook): order matching webhooks by id for deterministic fan-out
Previously WebhookListener.Handle fetched matching webhooks with
Find(&ws) without an explicit ORDER BY, so iteration order depended on
the DB driver. Add ORDER BY id ASC so the fan-out order is stable for
both project- and user-level webhooks, and update the sibling-blocking
regression test to insert webhooks with explicit ids so its ordering
assumption is robust to autoincrement state.
2026-04-09 09:26:04 +00:00
kolaente 85a3b3e469 fix(webhook): return error from delivery listener on nil payload
A nil payload signals data corruption or a version mismatch on the
event bus, not a safe-to-drop condition. Returning an error lets the
watermill retry middleware retry the message and eventually park it in
the poison queue instead of silently acking it.
2026-04-09 09:26:04 +00:00
kolaente bf87796669 test(webhook): handle deleted webhook gracefully between fan-out and delivery 2026-04-09 09:26:04 +00:00
kolaente 2a816f5db5 test(webhook): assert flaky webhook is retried until it succeeds 2026-04-09 09:26:04 +00:00
kolaente b09f50c6e2 test(webhook): assert good webhook delivered once despite sibling retries 2026-04-09 09:26:04 +00:00
kolaente bef58079d7 fix(webhook): dispatch one delivery event per webhook (#2569) 2026-04-09 09:26:04 +00:00
kolaente e5726239d4 feat(webhook): register WebhookDeliveryListener on startup 2026-04-09 09:26:04 +00:00
kolaente eef985011b feat(webhook): add WebhookDeliveryListener for per-webhook delivery 2026-04-09 09:26:04 +00:00
kolaente d89af8ce6d feat(webhook): add WebhookDeliveryEvent for per-webhook fan out 2026-04-09 09:26:04 +00:00
kolaente 38555b1120 test(webhook): add failing test for #2569 sibling webhook blocking
Also clear the example.com fixture webhook (id=1) in the existing
TestTaskUpdateWebhookE2E, since it now errors after sendWebhookPayload
returns non-nil for non-2xx responses.
2026-04-09 09:26:04 +00:00
kolaente 42b0f0ba77 fix(webhook): return error from sendWebhookPayload on non-2xx responses
Previously the HTTP response status was only logged, so retries never
triggered for failing webhooks and downstream fan-out bugs (#2569) were
impossible to exercise via tests. Returning an error lets the watermill
retry middleware do its job.
2026-04-09 09:26:04 +00:00
renovate[bot] 4415485675 chore(deps): update dependency vitest to v4.1.4 2026-04-09 08:59:51 +00:00
kolaente e7bc5a31e4 docs(shortcuts): show platform-aware delete key in keyboard shortcuts panel 2026-04-09 08:07:48 +00:00
kolaente cee2babc58 feat(tasks): use platform-aware delete shortcut on task detail view 2026-04-09 08:07:48 +00:00
Rhys McNeill 699c766049 fix: add timeouts to Gravatar, Unsplash, and SSRF-safe HTTP clients 2026-04-09 07:31:08 +00:00
kolaente 17a97cacfa refactor: use per-view IN clause for filter task deletion instead of batching 2026-04-09 07:25:57 +00:00
kolaente bfdcea6bd2 fix: batch delete conditions in filter view cron to avoid SQLite expression depth limit
The filter view cron built an unbounded builder.Or(deleteCond...) tree
that exceeded SQLite's 1000-node expression depth limit when many tasks
needed removal. Delete conditions are now processed in chunks of 500.

Ref: #2550
2026-04-09 07:25:57 +00:00
kolaente 2014343557 fix: catch ErrNeedsFullRecalculation in task creation position conflict resolution
resolvePositionConflictsAfterInsert now falls back to a full position
recalculation when resolveTaskPositionConflicts returns
ErrNeedsFullRecalculation, instead of bubbling the error up as HTTP 500.
This mirrors the existing fallback logic in the CLI repair command.

Ref: #2550
2026-04-09 07:25:57 +00:00
Frederick [Bot] 36cec5ccca chore(i18n): update translations via Crowdin 2026-04-09 01:16:09 +00:00
kolaente c5450fb55f fix: update user list test expectations for new fixture user 2026-04-08 09:49:14 +00:00
kolaente 119d7df796 fix: use assert.Empty instead of assert.Equal for empty string check 2026-04-08 09:49:14 +00:00