Compare commits

..
6 Commits
Author SHA1 Message Date
github-actions[bot] 7e0fd84c3c Bump versions for v13.02 2026-11-09 02:14:45 +00:00
Lauri Ojansivu 4023654d84 Prepare v13.02 release 2026-11-09 04:13:46 +02:00
Lauri Ojansivu d213e7b674 Merge remote-tracking branch 'origin/main' 2026-11-09 04:08:15 +02:00
Lauri Ojansivu 423ac8a145 German: "Due next week" filter says "Nächste Woche fällig", not "Diese Woche fällig".
The filter sidebar's due date options "Due this week" and "Due next week"
had the same German label, so two options returning different cards could
not be told apart. Fixed in de, de-AT, de_DE and de-CH, and the same copied
label in lb, sv and ts. In ak, "Due today" and "Due next week" both said
"search this"; both now say what they filter (low confidence for ak, lb
and ts).

tests/dueFilterDistinctLabels.test.cjs pins the German labels and fails
when any language gives two due date options the same label.

Thanks to d0g2342 and xet7 !

Fixes #6887.
2026-11-08 21:16:56 +02:00
Lauri Ojansivu 913acde0ef CHANGELOG: UnassignedArchiveBleed, GHSA-6xqf-jh8x-4wxc.
Thanks to tsurutanmen and xet7 !
2026-11-08 14:52:11 +02:00
Lauri Ojansivu 1513b2e98c UnassignedArchiveBleed: an assigned-only member archives and converts only the cards assigned to them.
GHSA-6xqf-jh8x-4wxc. The DDP methods archiveSelectedCards and
createBoardFromCard asked only for the board's write capability. The
Normal, assigned only role has it, but sees and changes only its own
cards, so such a member who knew a card's id archived any card of the
board - and its subtasks - and turned any card into a linked-board
card. Both methods now also ask assignedOnlyAllowsCard, as copyCard
does; archiveSelectedCards checks every card of the selection before it
archives the first, so a refused selection changes nothing.

A refusal is recorded in Admin Panel -> Problems as
UnassignedArchiveBleed (authz.assigned-card-write, medium, never
disabling the account). tests/assignedCardMethods.test.cjs drives the
reported case and fails for any server method that takes a card id and
decides by the board's write access alone;
tests/playwright/specs/assigned-card-methods.e2e.js replays the
reporter's calls against a running server.

Thanks to tsurutanmen and xet7 !
2026-11-08 14:50:53 +02:00
24 changed files with 300 additions and 35 deletions

No files matched your search

+26
View File
@@ -124,6 +124,32 @@ its reason.
</details>
# v13.02 2026-11-09 WeKan ® release
**In short:** **UnassignedArchiveBleed** is fixed: a member with the
**Normal, assigned only** role archives and converts only the cards assigned
to them, through every card method.
This release fixes the following CRITICAL SECURITY ISSUE of [UnassignedArchiveBleed](https://wekan.fi/hall-of-fame/unassignedarchivebleed/):
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1513b2e98c">An assigned-only member archives and converts only the cards assigned to them</a>. Thanks to tsurutanmen and xet7.</summary>
[UnassignedArchiveBleed](https://wekan.fi/hall-of-fame/unassignedarchivebleed/),
[GHSA-6xqf-jh8x-4wxc](https://github.com/wekan/wekan/security/advisories/GHSA-6xqf-jh8x-4wxc).
The DDP methods `archiveSelectedCards` and `createBoardFromCard` asked only
for the board's write capability, which the Normal, assigned only role has, so
such a member who knew a card's id archived any card of the board - and its
subtasks - and turned any card into a linked-board card. Both now also ask
the card, as `copyCard` does; a selection is checked whole before the first
card is archived. A refusal is shown in Admin Panel / Problems.
`tests/assignedCardMethods.test.cjs` fails for any server method that takes a
card id and decides by the board's write access alone.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v13.01 2026-11-08 WeKan ® release
**In short:** Translations: the strings still in English are written in **33
+8 -9
View File
@@ -5,6 +5,14 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v13.02 2026-11-09 WeKan ® release
**UnassignedArchiveBleed** is fixed: a member with the
**Normal, assigned only** role archives and converts only the cards assigned
to them, through every card method.
[All changes in v13.02](CHANGELOG-big.md#v1302-2026-11-09-wekan--release)
# v13.01 2026-11-08 WeKan ® release
Translations: the strings still in English are written in **33
@@ -46,12 +54,3 @@ attachments again, and the developer instructions cover the **weba** Gambas
companion repository.
[All changes in v12.98](CHANGELOG-big.md#v1298-2026-11-06-wekan--release)
# v12.97 2026-11-06 WeKan ® release
`build.sh` and the `releases/` scripts now work on **Arch Linux**
and **Omarchy**, and the release builds no longer lose the **OpenBSD arm64
bundle** to a smoke test that left WeKan running, or the **s390x snap** to
Launchpad outages that were counted as build failures.
[All changes in v12.97](CHANGELOG-big.md#v1297-2026-11-06-wekan--release)
+1 -1
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=13.01
ARG VERSION=13.02
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v13.01.0"
appVersion: "v13.02.0"
files:
userUploads:
- README.md
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-13.01-win64.zip](https://github.com/wekan/wekan/releases/download/v13.01/wekan-13.01-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-13.02-win64.zip](https://github.com/wekan/wekan/releases/download/v13.02/wekan-13.02-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-13.01-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-13.02-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+2 -2
View File
@@ -855,11 +855,11 @@
"filter-no-due-date": "Da a ɛsɛ sɛ ewie nni hɔ",
"filter-overdue": "Hwehwɛ ade yi",
"filter-due-any": "Awiei da biara (nea enni da nso ka ho)",
"filter-due-today": "Hwehwɛ ade yi",
"filter-due-today": "Ɛsɛ sɛ ewie nnɛ",
"filter-due-previous-week": "Ɛsɛ sɛ ɛba awiei nnawɔtwe a etwaam no",
"filter-due-next-month": "Ɛsɛ sɛ ɛba awiei bosome a edi hɔ no",
"filter-due-this-week": "Ɛsɛ sɛ ewie dapɛn yi",
"filter-due-next-week": "Hwehwɛ ade yi",
"filter-due-next-week": "Ɛsɛ sɛ ewie dapɛn a edi hɔ no",
"filter-due-tomorrow": "Ɛsɛ sɛ ewie ɔkyena",
"list-filter-label": "Yi mu Nhyehyɛe denam Ti asɛm",
"filter-clear": "Hwehwɛ ade yi",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "Diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "Nächste Woche fällig",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "nächste Woche fällig",
"filter-due-tomorrow": "morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "Diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "Nächste Woche fällig",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "Diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "Nächste Woche fällig",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "An der viregter Woch fälleg",
"filter-due-next-month": "Den nächste Mount fälleg",
"filter-due-this-week": "Dës Woche fällig",
"filter-due-next-week": "Dës Woche fällig",
"filter-due-next-week": "Déi nächst Woch fälleg",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Lëscht no Titel filtern",
"filter-clear": "Filter ewechhuelen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "Förföll förra veckan",
"filter-due-next-month": "Förfaller nästa månad",
"filter-due-this-week": "Förfaller denna vecka",
"filter-due-next-week": "Förfaller denna vecka",
"filter-due-next-week": "Förfaller nästa vecka",
"filter-due-tomorrow": "Förfaller imorgon",
"list-filter-label": "Filtrera lista efter titel",
"filter-clear": "Rensa filter",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "Leswi faneleke ku hela vhiki leri hundzeke",
"filter-due-next-month": "Leswi faneleke ku hela n’hweti leyi taka",
"filter-due-this-week": "fanele mhaka vhiki",
"filter-due-next-week": "fanele mhaka vhiki",
"filter-due-next-week": "fanele vhiki leri taka",
"filter-due-tomorrow": "fanele mundzuku",
"list-filter-label": "sefa nxaxamelo hi nhlokomhaka",
"filter-clear": "basisa sefa",
+3
View File
@@ -232,6 +232,9 @@ const CATALOG = {
// added or soft-deleted attachments, through the REST API, on a card that
// was no longer assigned to them. Only a caller with a kept card id reaches it.
'authz.assigned-attachment-write': { category: 'authz', bleed: 'UnassignedAttachBleed', severity: 'medium', cwe: 'CWE-863' },
// GHSA-6xqf-jh8x-4wxc: an assigned-only member archiving or converting a card
// not assigned to them through archiveSelectedCards / createBoardFromCard.
'authz.assigned-card-write': { category: 'authz', bleed: 'UnassignedArchiveBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.tenant': { category: 'authz', bleed: 'TenantBleed', severity: 'medium', cwe: 'CWE-862' },
'authz.search-session': { category: 'authz', bleed: 'SessionBleed', severity: 'medium', cwe: 'CWE-639' },
'authz.database': { category: 'authz', bleed: 'DatabaseBleed', severity: 'high', cwe: 'CWE-863' },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v13.01.0",
"version": "v13.02.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v13.01.0",
"version": "v13.02.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v13.01.0",
"version": "v13.02.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v13.01</title>
<title>WeKan REST API v13.02</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
<body>
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="WeKan REST API" class="sc-eStdmu iyAVQU">WeKan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="WeKan REST API" class="sc-eStdmu iyAVQU">WeKan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<script type="module">
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.01","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.02","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: WeKan REST API
version: v13.01
version: v13.02
description: |
The REST API allows you to control and extend WeKan with ease.
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1301,
appVersion = 1302,
# Increment this for every release.
appMarketingVersion = (defaultText = "13.01~2026-11-07"),
appMarketingVersion = (defaultText = "13.02~2026-11-09"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+29
View File
@@ -72,6 +72,18 @@ function getTranslatedString(key, fallback, options) {
// route used to check only READ access on the source board and clone the whole
// source card, so a read-only or assigned-only member could link - and copy -
// cards the method refuses them.
// GHSA-6xqf-jh8x-4wxc: an assigned-only member's write on a card not assigned
// to them. No view shows them such a card, so it is an attempt: recorded in
// Admin Panel -> Problems as UnassignedArchiveBleed (medium: never disables).
function recordUnassignedCardWrite(userId, source) {
try {
require('/server/lib/securityLog').record({
key: 'authz.assigned-card-write', action: 'blocked', source, userId,
detail: 'Assigned-only member changed a card not assigned to them.',
});
} catch (e) { /* logging must never break the guard */ }
}
async function createLinkedCardFor(userId, sourceCardId, boardId, swimlaneId, listId, sort, source) {
check(sourceCardId, String);
check(boardId, String);
@@ -162,6 +174,13 @@ Meteor.methods({
if (!sourceBoard || !allowIsBoardMemberWithWriteAccess(this.userId, sourceBoard)) {
throw new Meteor.Error('not-authorized');
}
// GHSA-6xqf-jh8x-4wxc: the board's write capability is not enough. An
// assigned-only member converts only a card assigned to them, as copyCard
// allows them only theirs (models/lib/boardCardScope.js).
if (!require('/models/lib/boardCardScope').assignedOnlyAllowsCard(sourceBoard, this.userId, card)) {
recordUnassignedCardWrite(this.userId, 'method:createBoardFromCard');
throw new Meteor.Error('not-authorized');
}
// CreateBleed: the card becomes a new working board.
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
await assertMayCreateBoard(this.userId, { source: 'method:createBoardFromCard', type: 'board' });
@@ -252,6 +271,16 @@ Meteor.methods({
throw new Meteor.Error('invalid-card-selection');
}
// GHSA-6xqf-jh8x-4wxc: the board's write capability is not enough. An
// assigned-only member archives only cards assigned to them - every card
// of the selection is checked before the first is archived, so a refused
// selection changes nothing.
const { assignedOnlyAllowsCard } = require('/models/lib/boardCardScope');
if (!cards.every(card => assignedOnlyAllowsCard(board, this.userId, card))) {
recordUnassignedCardWrite(this.userId, 'method:archiveSelectedCards');
throw new Meteor.Error('not-authorized');
}
// Validate the complete selection before changing the first card. Preserve
// the board order used by Multi-Selection for deterministic activity order.
const byId = new Map(cards.map(card => [card._id, card]));
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '13.01'
version: '13.02'
base: core24
confinement: strict
grade: stable
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-13.01-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-13.02-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v13.01/wekan-13.01-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v13.02/wekan-13.02-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v13.01/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v13.02/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+114
View File
@@ -0,0 +1,114 @@
'use strict';
// Guard: GHSA-6xqf-jh8x-4wxc (2026-11-08). The DDP methods archiveSelectedCards
// and createBoardFromCard asked only for the board's `write` capability. The
// normal-assigned-only role has write: true but sees only its own cards, so a
// member with that role archived - and hid - cards not assigned to them, and
// turned any card into a linked-board card. Both now ask the card as well
// (assignedOnlyAllowsCard), as copyCard does, and record a refusal.
// Run: node tests/assignedCardMethods.test.cjs
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
const { assignedOnlyAllowsCard } = require('../models/lib/boardCardScope');
const { memberCan } = require('../models/lib/boardRoleCapabilities');
const board = {
_id: 'B',
members: [
{ userId: 'owner', isActive: true, isAdmin: true },
{ userId: 'member', isActive: true, isNormalAssignedOnly: true },
{ userId: 'normal', isActive: true },
],
};
// The decision both methods make now: board write AND the card.
const mayChangeCard = (userId, card) => memberCan(board.members, userId, 'write') && assignedOnlyAllowsCard(board, userId, card);
test('the reported attack: an assigned-only member may not change a card not assigned to them', () => {
const theirs = { _id: 'C', boardId: 'B', assignees: ['someone-else'] };
// The old check alone let them through - that was the hole.
assert.equal(memberCan(board.members, 'member', 'write'), true);
assert.equal(mayChangeCard('member', theirs), false);
assert.equal(mayChangeCard('member', { _id: 'D', boardId: 'B' }), false, 'a card with no assignees is not theirs either');
});
test('their own card, and everybody else with write, still may', () => {
assert.equal(mayChangeCard('member', { _id: 'C', boardId: 'B', assignees: ['member'] }), true);
for (const userId of ['owner', 'normal']) {
assert.equal(mayChangeCard(userId, { _id: 'C', boardId: 'B', assignees: [] }), true);
}
});
function methodBody(src, name) {
const start = src.indexOf(`async ${name}(`);
assert.ok(start >= 0, `${name} exists`);
const next = src.slice(start + 1).search(/\n async [\w']+\(/);
return next < 0 ? src.slice(start) : src.slice(start, start + 1 + next);
}
test('archiveSelectedCards checks every card of the selection before archiving the first', () => {
const body = methodBody(read('server/models/cards.js'), 'archiveSelectedCards');
const check = body.indexOf('cards.every(card => assignedOnlyAllowsCard(board, this.userId, card))');
assert.ok(check > 0, 'every card is checked');
assert.ok(check < body.indexOf('.archive()'), 'before the first card is archived');
assert.match(body, /recordUnassignedCardWrite\(this\.userId, 'method:archiveSelectedCards'\);\n\s+throw new Meteor\.Error\('not-authorized'\)/);
});
test('createBoardFromCard checks the card before making a board or changing the card', () => {
const body = methodBody(read('server/models/cards.js'), 'createBoardFromCard');
const check = body.indexOf('assignedOnlyAllowsCard(sourceBoard, this.userId, card)');
assert.ok(check > 0);
assert.ok(check < body.indexOf('Boards.insertAsync'), 'before the board is made');
assert.ok(check < body.indexOf('Cards.updateAsync'), 'before the card is changed');
assert.match(body, /recordUnassignedCardWrite\(this\.userId, 'method:createBoardFromCard'\);\n\s+throw new Meteor\.Error\('not-authorized'\)/);
});
test('a refusal is recorded as UnassignedArchiveBleed, and logging never breaks the guard', () => {
const src = read('server/models/cards.js');
const fn = src.slice(src.indexOf('function recordUnassignedCardWrite'), src.indexOf('async function createLinkedCardFor'));
assert.match(fn, /try \{\n\s+require\('\/server\/lib\/securityLog'\)\.record\(\{\n\s+key: 'authz\.assigned-card-write', action: 'blocked', source, userId,/);
assert.match(fn, /\} catch \(e\) \{ \/\* logging must never break the guard \*\/ \}/);
assert.match(read('models/lib/securityCategories.js'),
/'authz\.assigned-card-write':\s*\{ category: 'authz', bleed: 'UnassignedArchiveBleed', severity: 'medium', cwe: 'CWE-863' \}/);
});
// The shape, anywhere on the server: a method that takes a card id and decides
// by the write capability of that card's own board alone. Every such method
// must also ask the card (assignedOnlyAllowsCard, mayCopyFromBoard, or
// canEditCardOrLinkedCard, which asks it). A check of a DESTINATION board
// (destBoard, destination, target) is a different question and not counted.
function serverFiles(dir) {
const out = [];
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...serverFiles(rel));
else if (entry.name.endsWith('.js')) out.push(rel);
}
return out;
}
test('negative: no method taking a card id decides by board write access alone', () => {
const offenders = [];
for (const file of serverFiles('server')) {
const src = read(file);
const starts = [...src.matchAll(/\n\s+async '?([\w.]+)'?\(([^)]*)\)\s*\{/g)];
starts.forEach((m, i) => {
const params = m[2];
if (!/\bcardIds?\b/.test(params)) return;
const body = src.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : undefined);
const boards = [...body.matchAll(/allowIsBoardMemberWithWriteAccess\(this\.userId, (\w+)\)/g)].map(x => x[1]);
const own = boards.filter(v => !/dest|destination|target/i.test(v));
if (!own.length) return;
if (!/assignedOnlyAllowsCard\(|mayCopyFromBoard\(|canEditCardOrLinkedCard\(/.test(body)) {
offenders.push(`${file}: ${m[1]}`);
}
});
}
assert.deepEqual(offenders, [], 'these decide a card write by the board alone');
});
+44
View File
@@ -0,0 +1,44 @@
'use strict';
// #6887: in German the filter sidebar's "Due next week" option said "Diese
// Woche fällig", the same as "Due this week", so the two options that return
// different cards could not be told apart. The same copy sat in de-AT, de_DE,
// de-CH, lb, sv and ts, and in ak "Due today" and "Due next week" were both
// "search this".
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { translationTokens } = require('../releases/translations/placeholder-tokens.mjs');
const dir = path.join(__dirname, '../imports/i18n/data');
const read = code => JSON.parse(fs.readFileSync(path.join(dir, `${code}.i18n.json`), 'utf8'));
const en = read('en');
const keys = Object.keys(en).filter(key => key.startsWith('filter-due-'));
assert.ok(keys.includes('filter-due-this-week') && keys.includes('filter-due-next-week'), 'the due filter keys exist');
// Positive: the German labels say which week.
for (const [code, thisWeek, nextWeek] of [
['de', 'Diese Woche fällig', 'Nächste Woche fällig'],
['de-AT', 'Diese Woche fällig', 'Nächste Woche fällig'],
['de_DE', 'Diese Woche fällig', 'Nächste Woche fällig'],
['de-CH', 'diese Woche fällig', 'nächste Woche fällig'],
]) {
const locale = read(code);
assert.equal(locale['filter-due-this-week'], thisWeek, `${code}: this week`);
assert.equal(locale['filter-due-next-week'], nextWeek, `${code}: next week`);
}
assert.equal(read('sv')['filter-due-next-week'], 'Förfaller nästa vecka', 'sv: next week');
// Negative: in no language do two due-date options share one label.
const locales = fs.readdirSync(dir).filter(file => file.endsWith('.i18n.json')).map(file => file.replace('.i18n.json', ''));
for (const code of locales) {
const locale = read(code);
const seen = new Map();
for (const key of keys) {
const value = locale[key];
if (!value) continue;
assert.deepEqual(translationTokens(value), translationTokens(en[key]), `${code}:${key}: exact placeholders`);
assert.ok(!seen.has(value), `${code}: ${key} and ${seen.get(value)} are both ${JSON.stringify(value)}`);
seen.set(value, key);
}
}
console.log(`due filter labels distinct in ${locales.length} locales`);
@@ -0,0 +1,50 @@
'use strict';
// GHSA-6xqf-jh8x-4wxc (2026-11-08): an assigned-only member passed the
// board-level write check of archiveSelectedCards and createBoardFromCard for
// every card on the board, so they archived - and hid - cards not assigned to
// them, and turned any card into a linked-board card. Both methods now ask the
// card as well, and a refusal is recorded in Admin Panel -> Problems.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken } = require('../helpers/auth');
const BASE_URL = process.env.WEKAN_BASE_URL || 'http://localhost:3000';
const call = (page, name, ...args) => page.evaluate(async ({ name, args }) => {
try { return { result: await window.Meteor.callAsync(name, ...args) }; } catch (error) { return { error: error.error }; }
}, { name, args });
test('an assigned-only member archives and converts only the cards assigned to them', async ({ page, user2, board }) => {
const [theirs, mine] = db.find('cards', { boardId: board.boardId });
const original = db.getBoard(board.boardId).members;
db.updateOne('boards', { _id: board.boardId }, { $set: { members: [...original,
{ userId: user2.id, isActive: true, isAdmin: false, isNormalAssignedOnly: true }] } });
db.updateOne('cards', { _id: theirs._id }, { $set: { assignees: [] } });
db.updateOne('cards', { _id: mine._id }, { $set: { assignees: [user2.id] } });
const recorded = () => db.find('eventlog', { bleed: 'UnassignedArchiveBleed' })
.filter(row => /archiveSelectedCards|createBoardFromCard/.test(row.source || ''))
.reduce((sum, row) => sum + (row.count || 1), 0);
const before = recorded();
try {
await page.goto(`${BASE_URL}/sign-in`);
await loginWithToken(page, user2.id, user2.token);
// The reported attack: a card that is not theirs.
expect((await call(page, 'archiveSelectedCards', board.boardId, [theirs._id])).error).toBe('not-authorized');
expect((await call(page, 'createBoardFromCard', theirs._id, 'x')).error).toBe('not-authorized');
// A selection with one card not theirs changes none of it.
expect((await call(page, 'archiveSelectedCards', board.boardId, [mine._id, theirs._id])).error).toBe('not-authorized');
expect(db.findOne('cards', { _id: theirs._id }).archived).not.toBe(true);
expect(db.findOne('cards', { _id: mine._id }).archived).not.toBe(true);
expect(db.findOne('cards', { _id: theirs._id }).type).not.toBe('cardType-linkedBoard');
// Each attempt is visible in Admin Panel -> Problems.
await expect.poll(recorded).toBeGreaterThanOrEqual(before + 3);
// Their own card: still allowed.
expect((await call(page, 'archiveSelectedCards', board.boardId, [mine._id])).result).toEqual({ archived: 1 });
expect(db.findOne('cards', { _id: mine._id }).archived).toBe(true);
} finally {
db.updateOne('boards', { _id: board.boardId }, { $set: { members: original } });
db.updateOne('cards', { _id: mine._id }, { $set: { archived: false } });
}
});