Skip to content

Commit e1e213d

Browse files
DavertMikclaude
andcommitted
fix(fillField): prevent rich-editor keystroke leak to sibling inputs
The IFRAME branch typed via the root-page keyboard against an iframe body that's not contenteditable (Monaco-style editors), so keystrokes landed on whatever the outer document had focused. Detection also climbed the DOM when the matched element looked hidden, which could pick up unrelated editors elsewhere on the page. Now: detection only walks down from the user's locator, the IFRAME branch re-detects the real input surface inside the iframe, and every focus/click is verified against document.activeElement before typing — a failed focus throws instead of leaking. Backing-textarea fixtures (TinyMCE legacy, CKEditor 4/5, CodeMirror 5, Summernote) wrapped so #editor is the visible container. Adds sibling-input regression coverage for IFRAME, CONTENTEDITABLE and HIDDEN_TEXTAREA paths plus a negative test for hidden backing locators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7eb4906 commit e1e213d

10 files changed

Lines changed: 236 additions & 37 deletions

File tree

lib/helper/extras/richTextEditor.js

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ function detectAndMark(el, opts) {
1313
const marker = opts.marker
1414
const kinds = opts.kinds
1515
const CE = '[contenteditable="true"], [contenteditable=""]'
16-
const MAX_HIDDEN_ASCENT = 3
1716

1817
function mark(kind, target) {
1918
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
@@ -33,32 +32,38 @@ function detectAndMark(el, opts) {
3332
if (iframe) return mark(kinds.IFRAME, iframe)
3433
const ce = el.querySelector(CE)
3534
if (ce) return mark(kinds.CONTENTEDITABLE, ce)
36-
const textarea = el.querySelector('textarea')
35+
const textareas = [...el.querySelectorAll('textarea')]
36+
const focusable = textareas.find(t => window.getComputedStyle(t).display !== 'none')
37+
const textarea = focusable || textareas[0]
3738
if (textarea) return mark(kinds.HIDDEN_TEXTAREA, textarea)
3839
}
3940

40-
const style = window.getComputedStyle(el)
41-
const isHidden =
42-
el.offsetParent === null ||
43-
(el.offsetWidth === 0 && el.offsetHeight === 0) ||
44-
style.display === 'none' ||
45-
style.visibility === 'hidden'
46-
if (!isHidden) return mark(kinds.STANDARD, el)
47-
48-
const isFormHidden = tag === 'INPUT' && el.type === 'hidden'
49-
if (isFormHidden) return mark(kinds.STANDARD, el)
50-
51-
let scope = el.parentElement
52-
for (let depth = 0; scope && depth < MAX_HIDDEN_ASCENT; depth++, scope = scope.parentElement) {
53-
const iframeNear = scope.querySelector('iframe')
54-
if (iframeNear) return mark(kinds.IFRAME, iframeNear)
55-
const ceNear = scope.querySelector(CE)
56-
if (ceNear) return mark(kinds.CONTENTEDITABLE, ceNear)
57-
const textareaNear = [...scope.querySelectorAll('textarea')].find(t => t !== el)
58-
if (textareaNear) return mark(kinds.HIDDEN_TEXTAREA, textareaNear)
41+
return mark(kinds.STANDARD, el)
42+
}
43+
44+
function detectInsideFrame(body, opts) {
45+
const marker = opts.marker
46+
const kinds = opts.kinds
47+
const CE = '[contenteditable="true"], [contenteditable=""]'
48+
body.ownerDocument.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
49+
50+
if (body.isContentEditable) return kinds.CONTENTEDITABLE
51+
52+
const ce = body.querySelector(CE)
53+
if (ce) {
54+
ce.setAttribute(marker, '1')
55+
return kinds.CONTENTEDITABLE
5956
}
6057

61-
return mark(kinds.STANDARD, el)
58+
const textareas = [...body.querySelectorAll('textarea')]
59+
const focusable = textareas.find(t => body.ownerDocument.defaultView.getComputedStyle(t).display !== 'none')
60+
const textarea = focusable || textareas[0]
61+
if (textarea) {
62+
textarea.setAttribute(marker, '1')
63+
return kinds.HIDDEN_TEXTAREA
64+
}
65+
66+
return kinds.CONTENTEDITABLE
6267
}
6368

6469
function selectAllInEditable(el) {
@@ -76,6 +81,17 @@ function unmarkAll(marker) {
7681
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
7782
}
7883

84+
function isActive(el) {
85+
return el.ownerDocument.activeElement === el
86+
}
87+
88+
async function assertFocused(target) {
89+
const focused = await target.evaluate(isActive)
90+
if (!focused) {
91+
throw new Error('fillField: rich editor target did not accept focus. Locator must point at the visible editor surface (a wrapper, iframe, or contenteditable) — not a hidden backing element.')
92+
}
93+
}
94+
7995
async function findMarked(helper) {
8096
const root = helper.page || helper.browser
8197
const raw = await root.$('[' + MARKER + ']')
@@ -97,16 +113,29 @@ export async function fillRichEditor(helper, el, value) {
97113

98114
if (kind === EDITOR.IFRAME) {
99115
await target.inIframe(async body => {
100-
await body.evaluate(selectAllInEditable)
101-
await body.typeText(value, { delay })
116+
const innerKind = await body.evaluate(detectInsideFrame, { marker: MARKER, kinds: EDITOR })
117+
const marked = await body.$('[' + MARKER + ']')
118+
const innerTarget = marked || body
119+
if (innerKind === EDITOR.HIDDEN_TEXTAREA) {
120+
await innerTarget.focus()
121+
await assertFocused(innerTarget)
122+
await innerTarget.selectAllAndDelete()
123+
await innerTarget.typeText(value, { delay })
124+
} else {
125+
await innerTarget.evaluate(selectAllInEditable)
126+
await assertFocused(innerTarget)
127+
await innerTarget.typeText(value, { delay })
128+
}
102129
})
103130
} else if (kind === EDITOR.HIDDEN_TEXTAREA) {
104131
await target.focus()
132+
await assertFocused(target)
105133
await target.selectAllAndDelete()
106134
await target.typeText(value, { delay })
107135
} else if (kind === EDITOR.CONTENTEDITABLE) {
108136
await target.click()
109137
await target.evaluate(selectAllInEditable)
138+
await assertFocused(target)
110139
await target.typeText(value, { delay })
111140
}
112141

test/data/app/view/form/richtext/ckeditor4.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
<body>
99
<h1>CKEditor 4</h1>
1010
<form id="richtext-form" method="post" action="/richtext_submit">
11-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
11+
<div id="editor">
12+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
</div>
1214
<input type="hidden" name="content" id="content-sync">
1315
<button type="submit" id="submit">Submit</button>
1416
</form>
1517
<script src="https://cdn.ckeditor.com/4.22.1/standard/ckeditor.js"></script>
1618
<script>
17-
CKEDITOR.replace('editor');
19+
CKEDITOR.replace('editor-inner');
1820
CKEDITOR.on('instanceReady', function(e) {
1921
window.__editor = e.editor;
2022
window.__editorContent = () => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>CKEditor 5 with sibling input</title>
7+
</head>
8+
<body>
9+
<h1>CKEditor 5 with sibling input</h1>
10+
<form id="richtext-form" method="post" action="/richtext_submit">
11+
<input id="outer-title" name="outer-title" placeholder="Title" type="search" autofocus>
12+
<div id="editor">
13+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
14+
</div>
15+
<input type="hidden" name="content" id="content-sync">
16+
<button type="submit" id="submit">Submit</button>
17+
</form>
18+
<script src="https://cdn.ckeditor.com/ckeditor5/41.4.2/classic/ckeditor.js"></script>
19+
<script>
20+
ClassicEditor.create(document.querySelector('#editor-inner'), {
21+
removePlugins: ['TextTransformation']
22+
}).then(editor => {
23+
window.__editor = editor;
24+
window.__editorContent = () => {
25+
const div = document.createElement('div');
26+
div.innerHTML = editor.getData() || '';
27+
return (div.textContent || '').replace(/ /g, ' ').trim();
28+
};
29+
window.__editorReady = true;
30+
});
31+
document.getElementById('richtext-form').addEventListener('submit', function() {
32+
document.getElementById('content-sync').value = window.__editorContent ? window.__editorContent() : '';
33+
});
34+
document.getElementById('outer-title').focus();
35+
</script>
36+
</body>
37+
</html>

test/data/app/view/form/richtext/ckeditor5.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
<body>
99
<h1>CKEditor 5</h1>
1010
<form id="richtext-form" method="post" action="/richtext_submit">
11-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
11+
<div id="editor">
12+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
</div>
1214
<input type="hidden" name="content" id="content-sync">
1315
<button type="submit" id="submit">Submit</button>
1416
</form>
1517
<script src="https://cdn.ckeditor.com/ckeditor5/41.4.2/classic/ckeditor.js"></script>
1618
<script>
17-
ClassicEditor.create(document.querySelector('#editor'), {
19+
ClassicEditor.create(document.querySelector('#editor-inner'), {
1820
removePlugins: ['TextTransformation']
1921
}).then(editor => {
2022
window.__editor = editor;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>CodeMirror 5 with sibling input</title>
7+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.css">
8+
<style>.CodeMirror { border: 1px solid #ccc; height: 200px; }</style>
9+
</head>
10+
<body>
11+
<h1>CodeMirror 5 with sibling input</h1>
12+
<form id="richtext-form" method="post" action="/richtext_submit">
13+
<input id="outer-title" name="outer-title" placeholder="Title" type="search" autofocus>
14+
<div id="editor">
15+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
16+
</div>
17+
<input type="hidden" name="content" id="content-sync">
18+
<button type="submit" id="submit">Submit</button>
19+
</form>
20+
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.js"></script>
21+
<script>
22+
const editor = CodeMirror.fromTextArea(document.getElementById('editor-inner'), {});
23+
window.__editor = editor;
24+
window.__editorContent = () => editor.getValue();
25+
window.__editorReady = true;
26+
27+
document.getElementById('richtext-form').addEventListener('submit', function() {
28+
document.getElementById('content-sync').value = window.__editorContent ? window.__editorContent() : '';
29+
});
30+
document.getElementById('outer-title').focus();
31+
</script>
32+
</body>
33+
</html>

test/data/app/view/form/richtext/codemirror5.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
<body>
1111
<h1>CodeMirror 5</h1>
1212
<form id="richtext-form" method="post" action="/richtext_submit">
13-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
<div id="editor">
14+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
15+
</div>
1416
<input type="hidden" name="content" id="content-sync">
1517
<button type="submit" id="submit">Submit</button>
1618
</form>
1719
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.js"></script>
1820
<script>
19-
const editor = CodeMirror.fromTextArea(document.getElementById('editor'), {});
21+
const editor = CodeMirror.fromTextArea(document.getElementById('editor-inner'), {});
2022
window.__editor = editor;
2123
window.__editorContent = () => editor.getValue();
2224
window.__editorReady = true;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>Monaco in iframe with sibling input</title>
7+
</head>
8+
<body>
9+
<h1>Monaco in iframe with sibling input</h1>
10+
<form id="outer-form" method="post" action="/richtext_submit">
11+
<input id="outer-title" name="outer-title" placeholder="Title" type="search" autofocus>
12+
<iframe id="monaco-frame" src="/form/richtext/monaco<?php echo $initial !== '' ? '?initial=' . urlencode($initial) : ''; ?>" style="width: 100%; height: 320px; border: 1px solid #ccc;"></iframe>
13+
<input type="hidden" name="content" id="content-sync">
14+
<button type="submit" id="submit">Submit</button>
15+
</form>
16+
<script>
17+
window.__editorReady = false;
18+
const frame = document.getElementById('monaco-frame');
19+
frame.addEventListener('load', function () {
20+
const poll = setInterval(function () {
21+
try {
22+
if (frame.contentWindow && frame.contentWindow.__editorReady) {
23+
window.__editorReady = true;
24+
clearInterval(poll);
25+
}
26+
} catch (e) {}
27+
}, 100);
28+
});
29+
document.getElementById('outer-form').addEventListener('submit', function () {
30+
try {
31+
const getValue = frame.contentWindow && frame.contentWindow.__editorContent;
32+
document.getElementById('content-sync').value = getValue ? getValue() : '';
33+
} catch (e) {
34+
document.getElementById('content-sync').value = '';
35+
}
36+
});
37+
document.getElementById('outer-title').focus();
38+
</script>
39+
</body>
40+
</html>

test/data/app/view/form/richtext/summernote.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,23 @@
99
<body>
1010
<h1>Summernote</h1>
1111
<form id="richtext-form" method="post" action="/richtext_submit">
12-
<div id="editor"></div>
12+
<div id="editor">
13+
<div id="editor-inner"></div>
14+
</div>
1315
<input type="hidden" name="content" id="content-sync">
1416
<button type="submit" id="submit">Submit</button>
1517
</form>
1618
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
1719
<script src="https://cdn.jsdelivr.net/npm/summernote@0.9.0/dist/summernote-lite.min.js"></script>
1820
<script>
1921
$(document).ready(function() {
20-
$('#editor').summernote({ height: 150 });
22+
$('#editor-inner').summernote({ height: 150 });
2123
const initial = <?php echo json_encode($initial, JSON_UNESCAPED_UNICODE); ?>;
22-
if (initial) $('#editor').summernote('code', initial.split(/\n{2,}/).map(p => '<p>' + p.replace(/</g, '&lt;') + '</p>').join(''));
23-
window.__editor = $('#editor');
24+
if (initial) $('#editor-inner').summernote('code', initial.split(/\n{2,}/).map(p => '<p>' + p.replace(/</g, '&lt;') + '</p>').join(''));
25+
window.__editor = $('#editor-inner');
2426
window.__editorContent = () => {
2527
const div = document.createElement('div');
26-
div.innerHTML = $('#editor').summernote('code') || '';
28+
div.innerHTML = $('#editor-inner').summernote('code') || '';
2729
return (div.textContent || '').replace(/\u00a0/g, ' ').trim();
2830
};
2931
window.__editorReady = true;

test/data/app/view/form/richtext/tinymce-legacy.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
<body>
99
<h1>TinyMCE (iframe)</h1>
1010
<form id="richtext-form" method="post" action="/richtext_submit">
11-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
11+
<div id="editor">
12+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
</div>
1214
<input type="hidden" name="content" id="content-sync">
1315
<button type="submit" id="submit">Submit</button>
1416
</form>
1517
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js" referrerpolicy="origin"></script>
1618
<script>
1719
tinymce.init({
18-
selector: '#editor',
20+
selector: '#editor-inner',
1921
license_key: 'gpl',
2022
promotion: false,
2123
setup: function(editor) {

test/helper/webapi.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,56 @@ export function tests() {
895895
})
896896
})
897897
}
898+
899+
describe('rich editor with sibling focused input — no keystroke leak', function () {
900+
async function openSiblingPage(page, initial) {
901+
const q = initial != null ? `?initial=${encodeURIComponent(initial)}` : ''
902+
await I.amOnPage(`/form/richtext/${page}${q}`)
903+
await I.waitForFunction(() => window.__editorReady === true, [], 30)
904+
}
905+
906+
const siblingCases = [
907+
{ name: 'iframe editor (Monaco)', page: 'monaco-with-sibling', selector: 'iframe', path: 'IFRAME' },
908+
{ name: 'hidden-textarea editor (CodeMirror)', page: 'codemirror5-with-sibling', selector: '#editor', path: 'HIDDEN_TEXTAREA' },
909+
{ name: 'contenteditable editor (CKEditor 5)', page: 'ckeditor5-with-sibling', selector: '#editor', path: 'CONTENTEDITABLE' },
910+
]
911+
912+
for (const tc of siblingCases) {
913+
describe(tc.name, () => {
914+
it(`fillField via ${tc.path} does not leak keystrokes to the outer focused input`, async () => {
915+
await openSiblingPage(tc.page)
916+
await I.fillField(tc.selector, 'Hello rich text world')
917+
expect(await I.grabValueFrom('#outer-title')).to.equal('')
918+
await I.click('#submit')
919+
await I.waitForElement('#result', 15)
920+
expect(await I.grabTextFrom('#result')).to.include('Hello rich text world')
921+
})
922+
923+
it(`fillField via ${tc.path} clears pre-populated content without touching the outer input`, async () => {
924+
await openSiblingPage(tc.page, 'PREVIOUSLY ENTERED DATA')
925+
await I.fillField(tc.selector, 'fresh replacement text')
926+
expect(await I.grabValueFrom('#outer-title')).to.equal('')
927+
await I.click('#submit')
928+
await I.waitForElement('#result', 15)
929+
const submitted = await I.grabTextFrom('#result')
930+
expect(submitted).to.include('fresh replacement text')
931+
expect(submitted).to.not.include('PREVIOUSLY ENTERED DATA')
932+
})
933+
})
934+
}
935+
936+
it('throws instead of leaking when locator points at a hidden backing element', async () => {
937+
await openSiblingPage('codemirror5-with-sibling')
938+
let caught = null
939+
try {
940+
await I.fillField('#editor-inner', 'should-not-leak')
941+
} catch (e) {
942+
caught = e
943+
}
944+
expect(caught, 'fillField on a display:none backing textarea must throw').to.not.equal(null)
945+
expect(await I.grabValueFrom('#outer-title')).to.equal('')
946+
})
947+
})
898948
})
899949

900950
describe('#clearField', () => {

0 commit comments

Comments
 (0)