Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4008737
fix(migration): serialize imports per user
kolaente Aug 30, 2026
0855e13
fix(tasks): hide assignee email addresses
kolaente Aug 30, 2026
3234f6e
fix(tasks): authorize both sides of relation deletion
kolaente Aug 30, 2026
10cf863
fix(users): reject link shares from user listings
kolaente Aug 30, 2026
92ba159
fix(sharing): restrict link share hash reads
kolaente Aug 30, 2026
107a634
fix(caldav): authorize task relations
kolaente Aug 30, 2026
b5e3291
fix(tasks): restrict subtask expansion by access
kolaente Aug 30, 2026
9048981
fix(tasks): require access for favorites
kolaente Aug 30, 2026
ed28b42
fix(sharing): restrict team attachment visibility
kolaente Aug 30, 2026
4dc60fd
fix(tasks): validate position target views
kolaente Aug 30, 2026
dbd092e
fix(auth): hide enabled totp secrets
kolaente Aug 30, 2026
51e4a58
fix(api): enforce token scopes for task expansions
kolaente Aug 30, 2026
527fbdc
fix(filters): bound expression complexity
kolaente Aug 30, 2026
416d2c5
fix(images): bound decode and resize dimensions
kolaente Aug 30, 2026
5650b49
fix(routes): rate limit basic auth failures
kolaente Aug 30, 2026
82c6832
fix(migration): cap csv rows while decoding
kolaente Aug 30, 2026
6853279
fix(migration): bound planka import resources
kolaente Aug 30, 2026
c555da3
fix(migration): bound vikunja file imports
kolaente Aug 30, 2026
19f7b51
chore: regenerate yaegi symbols
kolaente Aug 30, 2026
4e67e84
test(e2e): make team owner a fixture member
kolaente Aug 31, 2026
1ec8a75
test(migration): handle database id sequences
kolaente Aug 31, 2026
d55cfbc
fix(migration): reload conflicting claims after rollback
kolaente Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions config-raw.json
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,11 @@
"key": "tokenrefreshlimit",
"default_value": "60",
"comment": "The number of requests a user can make from the same IP to the session renewal routes\n(token refresh and the OAuth token endpoint) per minute. These are kept separate from\n\"noauthlimit\" because every logged-in client renews its access token periodically, which\nwould otherwise use up the budget reserved for login attempts. This limit cannot be disabled."
},
{
"key": "basicauthlimit",
"default_value": "10",
"comment": "The number of failed HTTP BasicAuth attempts (CalDAV, feeds) a client can make from the same IP per minute before further attempts are rejected with 429. Only failures count, so successful regular syncs are never limited. This limit is separate from \"noauthlimit\" so CalDAV clients cannot starve the login budget."
}
]
},
Expand Down Expand Up @@ -552,6 +557,36 @@
"key": "migration",
"comment": "To use any of the available migrators, you usually need to configure credentials for the appropriate service and enable it. Find instructions below on how to do this for the provided migrators.",
"children": [
{
"key": "maxcsvrows",
"default_value": "100000",
"comment": "The maximum number of data rows parsed from one CSV import, including generic CSV and TickTick backup files."
},
{
"key": "vikunjafile",
"children": [
{
"key": "maxsize",
"default_value": "256MB",
"comment": "The maximum total size of decompressed content a Vikunja export file may contain. Exports whose declared uncompressed size exceeds this are rejected before anything is read."
},
{
"key": "maxfiles",
"default_value": "10000",
"comment": "The maximum number of files a single Vikunja export may contain."
},
{
"key": "maxuserstorage",
"default_value": "1GB",
"comment": "How much file storage a single user may use through Vikunja export imports. Existing storage plus the planned import size must stay below this."
}
]
},
{
"key": "claimtimeout",
"default_value": "24h",
"comment": "How long a running migration's claim on the user's migration slot is trusted before another import for the same user may take it over. Only relevant when the instance crashed mid-import (normally the claim is released on success and failure). Real migrations can run longer than this; a takeover only happens after the timeout expires."
},
{
"key": "todoist",
"children": [
Expand Down
138 changes: 138 additions & 0 deletions frontend/src/views/user/settings/TOTP.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'
import {mount, flushPromises, type VueWrapper} from '@vue/test-utils'
import {setActivePinia, createPinia} from 'pinia'
import {createI18n} from 'vue-i18n'
import TOTP from './TOTP.vue'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
import en from '@/i18n/lang/en.json'

const get = vi.fn()
const enroll = vi.fn()
const enable = vi.fn()
const disable = vi.fn()
const qrcode = vi.fn(async () => new Blob(['fake-jpeg-bytes']))

vi.mock('@/services/totp', () => ({
default: class {
loading = false
get = get
enroll = enroll
enable = enable
disable = disable
qrcode = qrcode
},
}))

vi.mock('@/message', () => ({
success: vi.fn(),
error: vi.fn(),
}))

// Avoid the avatar request triggered by setUser.
vi.mock('@/models/user', async (importOriginal) => {
const original = await importOriginal<typeof import('@/models/user')>()
return {
...original,
fetchAvatarBlobUrl: vi.fn(async () => ''),
invalidateAvatarCache: vi.fn(),
}
})

const i18n = createI18n({legacy: false, locale: 'en', messages: {en}})

let wrapper: VueWrapper | undefined
let errors: unknown[] = []

function mountComponent() {
return mount(TOTP, {
global: {
plugins: [i18n],
stubs: {
Card: {template: '<div><slot /></div>'},
XButton: {
template: '<button type="button" v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>',
emits: ['click'],
},
FormField: true,
},
config: {
errorHandler(err) {
errors.push(err)
},
},
},
})
}

async function mountAndSettle() {
wrapper = mountComponent()
await flushPromises()
return wrapper
}

// Enabled responses omit the secret, so the UI must rely on `enabled` alone.
describe('TOTP settings', () => {
beforeEach(() => {
setActivePinia(createPinia())
errors = []
get.mockReset()
enroll.mockReset()
enable.mockReset()
disable.mockReset()
qrcode.mockClear()

const configStore = useConfigStore()
configStore.totpEnabled = true
const authStore = useAuthStore()
authStore.setUser({
id: 1,
username: 'user1',
isLocalUser: true,
} as never)
})

afterEach(() => {
wrapper?.unmount()
wrapper = undefined
})

it('shows the enroll button when totp is not enrolled', async () => {
get.mockRejectedValueOnce({response: {data: {code: 1016}}})

const w = await mountAndSettle()

expect(w.text()).toContain('Enroll')
expect(qrcode).not.toHaveBeenCalled()
expect(errors).toEqual([])
})

it('shows the enrollment UI with the qrcode while enrollment is incomplete', async () => {
get.mockResolvedValueOnce({secret: 'SHAREDSECRET', enabled: false, url: 'otpauth://totp/x'})

const w = await mountAndSettle()

expect(w.text()).toContain('SHAREDSECRET')
expect(qrcode).toHaveBeenCalledTimes(1)
expect(w.find('img').exists()).toBe(true)
})

it('shows the disable UI without the secret or a qrcode request when totp is enabled', async () => {
get.mockResolvedValueOnce({secret: '', enabled: true, url: ''})

const w = await mountAndSettle()

expect(w.text()).toContain("You've successfully set up two factor authentication!")
expect(w.text()).not.toContain('Enroll')
expect(w.text()).not.toContain('scan')
expect(w.find('img').exists()).toBe(false)
expect(qrcode).not.toHaveBeenCalled()

const disableBtn = w.findAll('button').find(b => b.text().toLowerCase().includes('disable'))
expect(disableBtn).toBeTruthy()
await disableBtn!.trigger('click')
await flushPromises()
expect(qrcode).not.toHaveBeenCalled()
expect(errors).toEqual([])
})
})
14 changes: 7 additions & 7 deletions frontend/src/views/user/settings/TOTP.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
:title="$t('user.settings.totp.title')"
>
<XButton
v-if="!totpEnrolled && totp.secret === ''"
v-if="!totp.enabled && totp.secret === ''"
:loading="totpService.loading"
@click="totpEnroll()"
>
Expand Down Expand Up @@ -40,7 +40,7 @@
{{ $t('misc.confirm') }}
</XButton>
</template>
<template v-else-if="totp.secret !== '' && totp.enabled">
<template v-else-if="totp.enabled">
<p>
{{ $t('user.settings.totp.setupSuccess') }}
</p>
Expand Down Expand Up @@ -104,7 +104,6 @@ useTitle(() => `${t('user.settings.totp.title')} - ${t('user.settings.title')}`)
const totpService = shallowReactive(new TotpService())
const totp = ref<ITotp>(new TotpModel())
const totpQR = ref('')
const totpEnrolled = ref(false)
const totpConfirmPasscode = ref('')
const totpDisableForm = ref(false)
const totpDisablePassword = ref('')
Expand All @@ -122,12 +121,15 @@ async function totpStatus() {
}
try {
totp.value = await totpService.get({})
totpSetQrCode()
// Enabled responses omit the secret, so only request a QR code during enrollment.
if (!totp.value.enabled) {
totpSetQrCode()
}
} catch(e: unknown) {
// Error code 1016 means totp is not enabled, we don't need an error in that case.
const err = e as {response?: {data?: {code?: number}}}
if (err.response?.data?.code === 1016) {
totpEnrolled.value = false
totp.value = new TotpModel()
return
}

Expand All @@ -142,7 +144,6 @@ async function totpSetQrCode() {

async function totpEnroll() {
totp.value = await totpService.enroll()
totpEnrolled.value = true
totpSetQrCode()
}

Expand All @@ -154,7 +155,6 @@ async function totpConfirm() {

async function totpDisable() {
await totpService.disable({password: totpDisablePassword.value})
totpEnrolled.value = false
totp.value = new TotpModel()
success({message: t('user.settings.totp.disableSuccess')})
}
Expand Down
1 change: 1 addition & 0 deletions frontend/tests/e2e/sharing/team.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ test.describe('Team permission tiers on shared projects', () => {
const [owner, member] = await UserFactory.create(2)
await createProjects(1)
const [team] = await TeamFactory.create(1, {id: 1, name: 'Shared Team', created_by_id: owner.id}, false)
await TeamMemberFactory.create(1, {team_id: team.id, user_id: owner.id, admin: true}, false)
await TeamMemberFactory.create(1, {team_id: team.id, user_id: member.id, admin: false}, false)
await TeamProjectFactory.create(1, {team_id: team.id, project_id: 1, permission: 1}, false)

Expand Down
12 changes: 12 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const (
RateLimitStore Key = `ratelimit.store`
RateLimitNoAuthRoutesLimit Key = `ratelimit.noauthlimit`
RateLimitTokenRefreshLimit Key = `ratelimit.tokenrefreshlimit`
RateLimitBasicAuthLimit Key = `ratelimit.basicauthlimit`

FilesBasePath Key = `files.basepath`
FilesMaxSize Key = `files.maxsize`
Expand All @@ -184,6 +185,11 @@ const (
MigrationMicrosoftTodoClientID Key = `migration.microsofttodo.clientid`
MigrationMicrosoftTodoClientSecret Key = `migration.microsofttodo.clientsecret`
MigrationMicrosoftTodoRedirectURL Key = `migration.microsofttodo.redirecturl`
MigrationClaimTimeout Key = `migration.claimtimeout`
MigrationMaxCSVRows Key = `migration.maxcsvrows`
MigrationVikunjaFileMaxSize Key = `migration.vikunjafile.maxsize`
MigrationVikunjaFileMaxFiles Key = `migration.vikunjafile.maxfiles`
MigrationVikunjaFileMaxUserStorage Key = `migration.vikunjafile.maxuserstorage`

CorsEnable Key = `cors.enable`
CorsOrigins Key = `cors.origins`
Expand Down Expand Up @@ -459,6 +465,7 @@ func initDefaultConfig() {
RateLimitStore.setDefault("memory")
RateLimitNoAuthRoutesLimit.setDefault(10)
RateLimitTokenRefreshLimit.setDefault(60)
RateLimitBasicAuthLimit.setDefault(10)
// Files
FilesBasePath.setDefault("files")
FilesMaxSize.setDefault("20MB")
Expand All @@ -480,6 +487,11 @@ func initDefaultConfig() {
MigrationTodoistEnable.setDefault(false)
MigrationTrelloEnable.setDefault(false)
MigrationMicrosoftTodoEnable.setDefault(false)
MigrationClaimTimeout.setDefault("24h")
MigrationMaxCSVRows.setDefault(100000)
MigrationVikunjaFileMaxSize.setDefault("256MB")
MigrationVikunjaFileMaxFiles.setDefault(10000)
MigrationVikunjaFileMaxUserStorage.setDefault("1GB")
// Avatar
AvatarGravaterExpiration.setDefault(3600)
AvatarGravatarBaseURL.setDefault("https://www.gravatar.com")
Expand Down
5 changes: 5 additions & 0 deletions pkg/files/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func (f *File) fileID() string {
return strconv.FormatInt(f.ID, 10)
}

// DeleteBlob removes a stored blob after its database row has been rolled back.
func DeleteBlob(id int64) error {
return storage.Remove(strconv.FormatInt(id, 10))
}

// LoadFileByID returns a file by its ID
func (f *File) LoadFileByID() (err error) {
f.File, err = storage.Open(f.fileID())
Expand Down
74 changes: 74 additions & 0 deletions pkg/migration/20260830162731.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package migration

import (
"fmt"
"strings"
"time"

"code.vikunja.io/api/pkg/db"

"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)

// Completed claims use NULL because every supported database allows repeated NULLs in unique indexes.
type migrationActiveUserClaim20260830162731 struct {
ID int64 `xorm:"bigint autoincr not null unique pk"`
UserID int64 `xorm:"bigint not null"`
MigratorName string `xorm:"varchar(255)"`
StartedAt time.Time `xorm:"not null"`
FinishedAt time.Time `xorm:"null"`
ActiveUserID *int64 `xorm:"bigint null unique"`
}

func (migrationActiveUserClaim20260830162731) TableName() string {
return "migration_status"
}

func addActiveUserClaim20260830162731(tx *xorm.Engine) error {
if err := partialSync(tx, migrationActiveUserClaim20260830162731{}); err != nil {
return err
}

// partialSync skips unique constraints; xorm's derived name prevents a later sync from duplicating the index.
query := "CREATE UNIQUE INDEX IF NOT EXISTS UQE_migration_status_active_user_id ON migration_status (active_user_id)"
if db.Type() == schemas.MYSQL {
// MySQL lacks CREATE INDEX IF NOT EXISTS, so tolerate its duplicate-index error below.
query = "CREATE UNIQUE INDEX UQE_migration_status_active_user_id ON migration_status (active_user_id)"
}

_, err := tx.Exec(query)
if err != nil && !strings.Contains(err.Error(), "Duplicate key name") {
return fmt.Errorf("could not create unique index on migration_status.active_user_id: %w", err)
}

return nil
}

func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260830162731",
Description: "Add nullable active_user_id with unique index to migration_status to serialize migrations per user",
Migrate: addActiveUserClaim20260830162731,
Rollback: func(_ *xorm.Engine) error {
return nil
},
})
}
Loading
Loading