diff --git a/.jules/palette.md b/.jules/palette.md index 0bbf5248..7134b530 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -115,3 +115,7 @@ ## $(date +%Y-%m-%d) - Prevent accidental data loss in inline editors **Learning:** Forms that take a long time to fill out (like a WBS editor) are prone to accidental closure by users pressing `Escape` or clicking cancel. This causes immediate data loss without any warning, resulting in frustration. **Action:** When working on editors that can be dismissed, track whether the user has modified any fields compared to their initial state. If there are changes, intercept the close action and present a confirmation dialog (`window.confirm`) to ensure they really want to discard their edits. Bypass this for intentional saves or explicit data overrides. + +## 2026-07-29 - Replace native disabled with aria-disabled for save button +**Learning:** Native \`disabled\` attributes prevent the element from receiving focus. In the inline editor, replacing it with \`aria-disabled="true"\` preserves focusability and allows intercepting clicks to provide helpful toast message feedback. +**Action:** When working on form submit buttons, use \`aria-disabled="true"\` and handle validation in JavaScript instead of relying on native \`disabled\` attribute. diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 17f338fe..00d64e2e 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -128,3 +128,7 @@ **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 ReDoS in XML Parsing +**Learning:** Using \`new RegExp\` with dynamically constructed strings (even if simple variables) can trigger SAST alerts for Regular Expression Denial-of-Service (ReDoS), especially when used on parsed file content. +**Action:** Replace dynamic regex construction for simple tag parsing with native string methods like \`indexOf\` and \`substring\`. This resolves the Semgrep warning \`javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp\` and improves safety. diff --git a/app.js b/app.js index b8c62279..469afa16 100644 --- a/app.js +++ b/app.js @@ -408,6 +408,13 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { return; } event.preventDefault(); + + const saveButton = form.querySelector('button[type="submit"]'); + if (saveButton && saveButton.getAttribute('aria-disabled') === 'true') { + showToast(saveButton.title || '입력값을 올바르게 수정해야 저장할 수 있습니다.'); + return; + } + renderDraftValidation.flush(); saveEditor(); }); @@ -1047,7 +1054,11 @@ function renderEditorValidation() { const saveButton = form.querySelector('button[type="submit"]'); if (saveButton) { - saveButton.disabled = errors.length > 0; + if (errors.length > 0) { + saveButton.setAttribute('aria-disabled', 'true'); + } else { + saveButton.removeAttribute('aria-disabled'); + } saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } diff --git a/cloud-sync.js b/cloud-sync.js index 7e44932b..25bc2ac3 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -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}>([^<]*)`)); - return m ? m[1].trim() : ''; + const startTag = "<" + name + ">"; + const endTag = ""; + const startIndex = block.indexOf(startTag); + if (startIndex === -1) return ''; + const contentStart = startIndex + startTag.length; + const endIndex = block.indexOf(endTag, contentStart); + if (endIndex === -1) return ''; + return block.substring(contentStart, endIndex).trim(); }; const unescape = (s) => s .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') diff --git a/package.json b/package.json index 9ae8b292..4f2476f9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { - "@hono/node-server": "^1.19.14", + "@hono/node-server": "^1.19.17", "hono": "^4.12.27" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bffabf92..21fba140 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@hono/node-server': - specifier: ^1.19.14 - version: 1.19.14(hono@4.12.28) + specifier: ^1.19.17 + version: 1.19.17(hono@4.12.28) hono: specifier: ^4.12.27 version: 4.12.28 @@ -24,8 +24,8 @@ importers: packages: - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -63,7 +63,7 @@ packages: snapshots: - '@hono/node-server@1.19.14(hono@4.12.28)': + '@hono/node-server@1.19.17(hono@4.12.28)': dependencies: hono: 4.12.28 diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 96c69057..d9051576 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -1333,4 +1333,29 @@ test.describe('ScopeWeave Planner - Palette UX Enhancements', () => { await expect(backBtn).toHaveAttribute('title', '작업 목록으로 돌아가기 (Esc)'); await expect(backBtn).toHaveAttribute('aria-keyshortcuts', 'Escape'); }); + + test('preserves save button focusability by using aria-disabled and shows feedback on click', async ({ page }) => { + await page.goto('./'); + + // Open editor + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + + // Clear required phase field to trigger validation error + await page.locator('[data-testid="editor-phase"]').fill(''); + + const saveButton = page.locator('.editor-panel button[type="submit"]'); + + // Should use aria-disabled instead of native disabled + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).not.toHaveAttribute('disabled', ''); + + // Should still be focusable + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + + // Should show toast feedback when clicked + await saveButton.click({ force: true }); + await expect(page.locator('#toast')).toHaveText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); + await expect(page.locator('#toast')).toHaveClass(/show/); + }); });