Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,8 @@
**Vulnerability:** The backend CSV export for audit logs neutralized `=`, `+`, `-`, and `@` but failed to neutralize `|` (pipe) characters, allowing potential DDE (Dynamic Data Exchange) injection if exported logs were opened in spreadsheet software.
**Learning:** Spreadsheet formula defenses must cover all command-style prefixes including `|` across all CSV export boundaries, both frontend and backend.
**Prevention:** Update the sanitization regex in the backend export function to `/^[=+\-@|]/` so that all potentially executable spreadsheet payloads are prefixed with a single quote.

## 2026-07-29 - Prevent CSV DDE injection bypass via leading whitespace
**Vulnerability:** The backend CSV export for audit logs neutralized `=, +, -, @, |` but failed to account for leading whitespace. Since spreadsheet software often ignores leading spaces, payloads like `" =cmd|'/C calc'!A0"` could bypass the filter and execute DDE commands.
**Learning:** Spreadsheet formula sanitization must explicitly target leading whitespace preceding formula/DDE prefixes. A regex strictly anchored to the start of the string (`^`) without whitespace allowance (`\s*`) will leave the application vulnerable.
**Prevention:** Update the sanitization regex across all boundaries (e.g. backend export) to `/^\s*[=+\-@|]/` to ensure payloads with leading spaces are safely neutralized.
10 changes: 8 additions & 2 deletions cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,14 @@ function openReportModal() {
// hand-edited files ever matter.
export function parseMsProjectXml(xml) {
const tag = (block, name) => {
const m = block.match(new RegExp(`<${name}>([^<]*)</${name}>`));
return m ? m[1].trim() : '';
const startTag = `<${name}>`;
const endTag = `</${name}>`;
const startIdx = block.indexOf(startTag);
if (startIdx === -1) return '';
const valStartIdx = startIdx + startTag.length;
const endIdx = block.indexOf(endTag, valStartIdx);
if (endIdx === -1) return '';
return block.substring(valStartIdx, endIdx).trim();
};
const unescape = (s) => s
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"fuzz": "node --test tests/fuzz/*.mjs"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"@hono/node-server": "^2.0.12",
"hono": "^4.12.27"
},
"devDependencies": {
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => {
// = + - @ | are prefixed with ' so spreadsheets treat them as text.
const csvCell = (v) => {
let s = v == null ? '' : String(v);
if (/^[=+\-@|]/.test(s)) s = `'${s}`;
if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`;
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta'];
Expand Down
2 changes: 1 addition & 1 deletion tests/api/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ assert.ok((r.headers.get('content-type') || '').startsWith('text/csv'), 'text/cs
const auditCsv = await r.text();
assert.ok(auditCsv.startsWith('id,createdAt,actorEmail,action'), 'csv header');
assert.ok(auditCsv.includes('project.create'), 'contains audited actions');
assert.ok(!/^[=+\-@]/m.test(auditCsv.split('\r\n')[1] || ''), 'formula-injection guarded');
assert.ok(!/^\s*[=+\-@|]/m.test(auditCsv.split('\r\n')[1] || ''), 'formula-injection guarded');
r = await req(`/api/orgs/${orgAId}/audit?format=csv`, { headers: oauth });
assert.equal(r.status, 403, 'non-manager audit csv β†’ 403');

Expand Down
Loading