Skip to content

Commit 63e60bc

Browse files
committed
🔒 Validate attribute-view select option colors and escape them at render sites to prevent stored XSS GHSA-m7cc-jh9q-wxg8
1 parent 01a9083 commit 63e60bc

11 files changed

Lines changed: 82 additions & 22 deletions

File tree

app/src/protyle/render/av/attributeValue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export const genAVValueHTML = (value: IAVCellValue, dateFormat: TAVDateFormat =
136136
if (value.type === "select" && index > 0) {
137137
return;
138138
}
139-
html += `<span class="b3-chip b3-chip--middle" style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">${escapeHtml(item.content)}</span>`;
139+
html += `<span class="b3-chip b3-chip--middle" style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">${escapeHtml(item.content)}</span>`;
140140
});
141141
break;
142142
case "mAsset":

app/src/protyle/render/av/cell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,7 @@ export const renderCell = (cellValue: IAVCellValue, rowIndex = 0, showIcon = tru
11161116
const tooltip = desc ?
11171117
` data-position="north" aria-label="${escapeAriaLabel(item.content)}<div class='ft__on-surface'>${escapeAriaLabel(desc)}</div>"` :
11181118
"";
1119-
text += `<span class="b3-chip${desc ? " ariaLabel" : ""}"${tooltip} style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">${escapeHtml(item.content)}</span>`;
1119+
text += `<span class="b3-chip${desc ? " ariaLabel" : ""}"${tooltip} style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">${escapeHtml(item.content)}</span>`;
11201120
});
11211121
} else if (cellValue.type === "date") {
11221122
const dataValue = cellValue ? cellValue.date : null;

app/src/protyle/render/av/col.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,10 @@ export const getEditHTML = (options: {
135135
}
136136
colData.options.forEach(item => {
137137
const airaLabel = item.desc ? `${escapeAriaLabel(item.name)}<div class='ft__on-surface'>${escapeAriaLabel(item.desc || "")}</div>` : "";
138-
html += `<button class="b3-menu__item${html ? "" : " b3-menu__item--current"}" draggable="true" data-name="${escapeAttr(item.name)}" data-desc="${escapeAttr(item.desc || "")}" data-color="${item.color}">
138+
html += `<button class="b3-menu__item${html ? "" : " b3-menu__item--current"}" draggable="true" data-name="${escapeAttr(item.name)}" data-desc="${escapeAttr(item.desc || "")}" data-color="${escapeAttr(item.color)}">
139139
<svg class="b3-menu__icon fn__grab"><use xlink:href="#iconDrag"></use></svg>
140140
<div class="fn__flex-1 ariaLabel" data-position="parentW" aria-label="${airaLabel}">
141-
<span class="b3-chip" style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">
141+
<span class="b3-chip" style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">
142142
<span class="fn__ellipsis">${escapeHtml(item.name)}</span>
143143
</span>
144144
</div>

app/src/protyle/render/av/filter.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ const genInlineSelectHTML = (filter: IAVFilter, colData: IAVColumn, path: string
693693

694694
// 触发器:显示已选值的 chip(与表格单元格样式一致),无选中时显示 placeholder + 下拉箭头
695695
const selectedChips = selectedValues.map((item: IAVCellSelectValue) => {
696-
return `<span class="b3-chip b3-chip--middle av__select-chip" style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">${escapeHtml(item.content)}</span>`;
696+
return `<span class="b3-chip b3-chip--middle av__select-chip" style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">${escapeHtml(item.content)}</span>`;
697697
}).join("");
698698
const triggerContent = selectedChips || `<span class="ft__on-surface fn__ellipsis">${placeholder}</span>`;
699699
const trigger = `<span class="av__select-trigger" data-type="selectTrigger" data-path="${path}">${triggerContent}<svg class="av__select-trigger-arrow"><use xlink:href="#iconDown"></use></svg></span>`;
@@ -704,9 +704,9 @@ const genInlineSelectHTML = (filter: IAVFilter, colData: IAVColumn, path: string
704704
: "";
705705
const chips = options.map((option: { name: string; color: string; desc?: string }) => {
706706
const selected = selectedValues.some((s: IAVCellSelectValue) => s.content === option.name);
707-
return `<button type="button" class="av__select-option" data-name="${escapeAttr(option.name)}" data-color="${option.color}" data-type="selectOption" data-path="${path}">
707+
return `<button type="button" class="av__select-option" data-name="${escapeAttr(option.name)}" data-color="${escapeAttr(option.color)}" data-type="selectOption" data-path="${path}">
708708
<svg class="av__select-option-check"><use xlink:href="#${selected ? "iconCheck" : "iconUncheck"}"></use></svg>
709-
<span class="b3-chip b3-chip--middle" style="background-color:var(--b3-font-background${option.color});color:var(--b3-font-color${option.color})"><span class="fn__ellipsis">${escapeHtml(option.name)}</span></span>
709+
<span class="b3-chip b3-chip--middle" style="background-color:var(--b3-font-background${escapeAttr(option.color)});color:var(--b3-font-color${escapeAttr(option.color)})"><span class="fn__ellipsis">${escapeHtml(option.name)}</span></span>
710710
</button>`;
711711
}).join("");
712712
const dropdown = `<div class="av__select-dropdown" data-type="selectDropdown" data-path="${path}" data-single="${allowMultiple ? "false" : "true"}" data-value-type="${valueType}" style="display:none;">
@@ -1200,7 +1200,7 @@ export const bindInlineFilterEvents = (panelElement: HTMLElement, data: IAV, pro
12001200
if (itemUseElement && itemUseElement.getAttribute("xlink:href") === "#iconCheck") {
12011201
const name = item.dataset.name;
12021202
const color = item.dataset.color;
1203-
selectedChips.push(`<span class="b3-chip b3-chip--middle av__select-chip" style="background-color:var(--b3-font-background${color});color:var(--b3-font-color${color})">${escapeHtml(name)}</span>`);
1203+
selectedChips.push(`<span class="b3-chip b3-chip--middle av__select-chip" style="background-color:var(--b3-font-background${escapeAttr(color)});color:var(--b3-font-color${escapeAttr(color)})">${escapeHtml(name)}</span>`);
12041204
}
12051205
});
12061206
const contentHTML = selectedChips.join("") || `<span class="ft__on-surface fn__ellipsis">${placeholderStr}</span>`;

app/src/protyle/render/av/groups.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {unicode2Emoji} from "../../../emoji";
22
import {getColIconByType} from "./col";
3-
import {escapeHtml} from "../../../util/escape";
3+
import {escapeAttr, escapeHtml} from "../../../util/escape";
44
import {setPosition} from "../../../util/setPosition";
55
import {getFieldsByData} from "./view";
66
import {fetchSyncPost} from "../../../util/fetch";
@@ -198,7 +198,7 @@ export const getGroupsHTML = (columns: IAVColumn[], view: IAVView) => {
198198
let titleHTML = `<div class="b3-menu__label fn__flex-1 fn__ellipsis">${item.name || ""}</div>`;
199199
if (item.groupValue?.mSelect?.length > 0) {
200200
titleHTML = `<div class="fn__flex-1">
201-
<span class="b3-chip" style="background-color:var(--b3-font-background${item.groupValue.mSelect[0].color});color:var(--b3-font-color${item.groupValue.mSelect[0].color})">
201+
<span class="b3-chip" style="background-color:var(--b3-font-background${escapeAttr(item.groupValue.mSelect[0].color)});color:var(--b3-font-color${escapeAttr(item.groupValue.mSelect[0].color)})">
202202
<span class="fn__ellipsis">${escapeHtml(item.groupValue.mSelect[0].content)}</span>
203203
</span>
204204
</div>`;

app/src/protyle/render/av/kanban/render.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const getKanbanTitleHTML = (group: IAVView, counter: number, draggable: boolean)
2020
let optionMenuHTML = "";
2121
if (["mSelect", "select"].includes(group.groupValue.type)) {
2222
group.groupValue.mSelect.forEach((item) => {
23-
nameHTML += `<span class="b3-chip" style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">${escapeHtml(item.content)}</span>`;
23+
nameHTML += `<span class="b3-chip" style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">${escapeHtml(item.content)}</span>`;
2424
});
2525
if (draggable && group.groupValue.mSelect.length === 1) {
2626
const value = group.groupValue.mSelect[0];
@@ -193,7 +193,7 @@ export const renderKanban = async (options: {
193193
}
194194
if (isSelectGroup) {
195195
if (group.groupValue.mSelect && group.groupValue.mSelect.length > 0) {
196-
selectBg = `style="--b3-av-kanban-background: var(--b3-font-background${group.groupValue.mSelect[0].color});"`;
196+
selectBg = `style="--b3-av-kanban-background: var(--b3-font-background${escapeAttr(group.groupValue.mSelect[0].color)});"`;
197197
} else {
198198
selectBg = 'style="--b3-av-kanban-background: var(--b3-border-color);"';
199199
}

app/src/protyle/render/av/render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ export const getGroupTitleHTML = (group: IAVView, counter: number) => {
274274
let nameHTML = "";
275275
if (["mSelect", "select"].includes(group.groupValue.type)) {
276276
group.groupValue.mSelect.forEach((item) => {
277-
nameHTML += `<span class="b3-chip" style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">${escapeHtml(item.content)}</span>`;
277+
nameHTML += `<span class="b3-chip" style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">${escapeHtml(item.content)}</span>`;
278278
});
279279
} else if (group.groupValue.type === "checkbox") {
280280
nameHTML = `<svg style="width:calc(1.625em - 12px);height:calc(1.625em - 12px)"><use xlink:href="#icon${group.groupValue.checkbox.checked ? "Check" : "Uncheck"}"></use></svg>`;

app/src/protyle/render/av/select.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ const filterSelectHTML = (key: string, options: {
3737
(key.toLowerCase().indexOf(item.name.toLowerCase()) > -1 ||
3838
item.name.toLowerCase().indexOf(key.toLowerCase()) > -1)) {
3939
const airaLabel = item.desc ? `${escapeAriaLabel(item.name)}<div class='ft__on-surface'>${escapeAriaLabel(item.desc || "")}</div>` : "";
40-
html += `<button data-type="addColOptionOrCell" class="b3-menu__item${currentName === item.name ? " b3-menu__item--current" : ""}" data-name="${escapeAttr(item.name)}" data-desc="${escapeAttr(item.desc || "")}" draggable="true" data-color="${item.color}">
40+
html += `<button data-type="addColOptionOrCell" class="b3-menu__item${currentName === item.name ? " b3-menu__item--current" : ""}" data-name="${escapeAttr(item.name)}" data-desc="${escapeAttr(item.desc || "")}" draggable="true" data-color="${escapeAttr(item.color)}">
4141
<svg class="b3-menu__icon fn__grab"><use xlink:href="#iconDrag"></use></svg>
4242
<div class="fn__flex-1 ariaLabel" data-position="parentW" aria-label="${airaLabel}">
43-
<span class="b3-chip" style="background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})">
43+
<span class="b3-chip" style="background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})">
4444
<span class="fn__ellipsis">${escapeHtml(item.name)}</span>
4545
</span>
4646
</div>
@@ -728,7 +728,7 @@ export const getSelectHTML = (fields: IAVColumn[], cellElements: HTMLElement[],
728728
visibleValues?.forEach((item) => {
729729
const option = colData.options?.find((colOption) => colOption.name === item.content);
730730
selected.push(item.content);
731-
selectedHTML += `<div class="b3-chip b3-chip--middle${canSort ? " fn__grab" : " b3-chip--pointer"}" data-content="${escapeAttr(item.content)}" data-name="${escapeAttr(item.content)}" data-desc="${escapeAttr(option?.desc || "")}" data-color="${escapeAttr(option?.color || item.color)}" data-value-color="${escapeAttr(item.color)}" style="white-space: nowrap;max-width:100%;background-color:var(--b3-font-background${item.color});color:var(--b3-font-color${item.color})"><span class="fn__ellipsis">${escapeHtml(item.content)}</span><svg class="b3-chip__close" data-type="removeCellOption"><use xlink:href="#iconClose"></use></svg></div>`;
731+
selectedHTML += `<div class="b3-chip b3-chip--middle${canSort ? " fn__grab" : " b3-chip--pointer"}" data-content="${escapeAttr(item.content)}" data-name="${escapeAttr(item.content)}" data-desc="${escapeAttr(option?.desc || "")}" data-color="${escapeAttr(option?.color || item.color)}" data-value-color="${escapeAttr(item.color)}" style="white-space: nowrap;max-width:100%;background-color:var(--b3-font-background${escapeAttr(item.color)});color:var(--b3-font-color${escapeAttr(item.color)})"><span class="fn__ellipsis">${escapeHtml(item.content)}</span><svg class="b3-chip__close" data-type="removeCellOption"><use xlink:href="#iconClose"></use></svg></div>`;
732732
});
733733

734734
return `<div class="b3-menu__items" style="display: flex;flex-direction: column;flex: 1;">

kernel/av/av.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"os"
2525
"path/filepath"
2626
"sort"
27+
"strconv"
2728
"strings"
2829

2930
"github.com/88250/gulu"
@@ -260,6 +261,19 @@ type SelectOption struct {
260261
Desc string `json:"desc"` // 选项描述
261262
}
262263

264+
// FilterColorValue 校验选项颜色值,仅允许空字符串或 1-14 的调色板索引,非法值返回空字符串
265+
func FilterColorValue(color string) string {
266+
color = strings.TrimSpace(color)
267+
if "" == color {
268+
return ""
269+
}
270+
n, err := strconv.Atoi(color)
271+
if nil != err || 1 > n || 14 < n {
272+
return ""
273+
}
274+
return strconv.Itoa(n)
275+
}
276+
263277
// View 描述了视图的结构。
264278
type View struct {
265279
ID string `json:"id"` // 视图 ID

kernel/av/color_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SiYuan - Refactor your thinking
2+
// Copyright (c) 2020-present, b3log.org
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
package av
18+
19+
import "testing"
20+
21+
func TestFilterColorValuePreservesPaletteIndex(t *testing.T) {
22+
for _, color := range []string{"", "1", "14"} {
23+
if filtered := FilterColorValue(color); filtered != color {
24+
t.Fatalf("palette color was changed [expected=%q, actual=%q]", color, filtered)
25+
}
26+
}
27+
if filtered := FilterColorValue(" 3 "); filtered != "3" {
28+
t.Fatalf("palette color with whitespace was not trimmed [%q]", filtered)
29+
}
30+
}
31+
32+
func TestFilterColorValueRejectsUnsafeValues(t *testing.T) {
33+
for _, color := range []string{
34+
`1);color:red" onmouseover="alert(1)" x="`,
35+
"0",
36+
"15",
37+
"1.5",
38+
"abc",
39+
`"`,
40+
"<script>",
41+
} {
42+
if filtered := FilterColorValue(color); "" != filtered {
43+
t.Fatalf("unsafe color value was preserved [input=%q, actual=%q]", color, filtered)
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)