Skip to content

Commit 1deda06

Browse files
authored
fix: change default link protocol (#2319)
* fix: change default link protocol * fix: default link protocol to https without mangling existing http URLs * fix: default link protocol to https and sanitize href before use
1 parent 25f0aad commit 1deda06

2 files changed

Lines changed: 108 additions & 12 deletions

File tree

packages/super-editor/src/components/toolbar/LinkInput.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,4 +843,90 @@ describe('LinkInput - getLinkHrefAtSelection type safety and boundary checking',
843843
expect(mockClosePopover).not.toHaveBeenCalled();
844844
});
845845
});
846+
847+
describe('URL normalization', () => {
848+
it('defaults bare domains to https when submitting a new link', async () => {
849+
const mockEditor = createMockEditor();
850+
mockEditor.options = { documentMode: 'editing' };
851+
852+
const wrapper = mount(LinkInput, {
853+
props: {
854+
editor: mockEditor,
855+
closePopover: mockClosePopover,
856+
showInput: true,
857+
},
858+
});
859+
860+
await nextTick();
861+
await nextTick();
862+
863+
await wrapper.find('input[name="link"]').setValue('example.com');
864+
await nextTick();
865+
866+
wrapper.vm.handleSubmit();
867+
868+
expect(mockEditor.commands.toggleLink).toHaveBeenCalledWith(
869+
expect.objectContaining({ href: 'https://example.com' }),
870+
);
871+
});
872+
873+
it('preserves explicit http links when submitting an existing link', async () => {
874+
const mockEditor = createMockEditor();
875+
mockEditor.options = { documentMode: 'editing' };
876+
const linkMark = mockEditor.state.schema.marks.link;
877+
mockEditor.state.selection.$from.nodeAfter = {
878+
marks: [{ type: linkMark, attrs: { href: 'http://example.com' } }],
879+
};
880+
881+
const wrapper = mount(LinkInput, {
882+
props: {
883+
editor: mockEditor,
884+
closePopover: mockClosePopover,
885+
showInput: true,
886+
},
887+
});
888+
889+
await nextTick();
890+
await nextTick();
891+
892+
wrapper.vm.handleSubmit();
893+
894+
expect(mockEditor.commands.toggleLink).toHaveBeenCalledWith(
895+
expect.objectContaining({ href: 'http://example.com' }),
896+
);
897+
});
898+
899+
it('blocks unsafe schemes in both submit and open-link flows', async () => {
900+
const mockEditor = createMockEditor();
901+
mockEditor.options = { documentMode: 'editing' };
902+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
903+
904+
const wrapper = mount(LinkInput, {
905+
props: {
906+
editor: mockEditor,
907+
closePopover: mockClosePopover,
908+
showInput: true,
909+
},
910+
});
911+
912+
await nextTick();
913+
await nextTick();
914+
915+
await wrapper.find('input[name="link"]').setValue('javascript:foo.bar()');
916+
await nextTick();
917+
918+
const openLinkBtn = wrapper.find('.open-link-icon');
919+
expect(openLinkBtn.classes()).toContain('disabled');
920+
921+
wrapper.vm.handleSubmit();
922+
await openLinkBtn.trigger('click');
923+
924+
expect(wrapper.vm.urlError).toBe(true);
925+
expect(mockEditor.commands.toggleLink).not.toHaveBeenCalled();
926+
expect(mockClosePopover).not.toHaveBeenCalled();
927+
expect(openSpy).not.toHaveBeenCalled();
928+
929+
openSpy.mockRestore();
930+
});
931+
});
846932
});

packages/super-editor/src/components/toolbar/LinkInput.vue

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup>
22
import { ref, computed, watch, onMounted } from 'vue';
3+
import { sanitizeHref } from '@superdoc/url-validation';
34
import { toolbarIcons } from './toolbarIcons.js';
45
import { useHighContrastMode } from '../../composables/use-high-contrast-mode';
56
import { TextSelection } from 'prosemirror-state';
@@ -118,21 +119,22 @@ const text = ref('');
118119
const rawUrl = ref('');
119120
const isAnchor = ref(false);
120121
121-
// Prepend http if missing
122+
const HAS_PROTOCOL = /^[a-z][a-z0-9+.-]*:/i;
123+
124+
// Default to https:// when no scheme is specified. Validation stays centralized in sanitizeHref.
122125
const url = computed(() => {
123126
if (!rawUrl.value) return '';
124-
if (!rawUrl.value.startsWith('http') && !rawUrl.value.startsWith('#')) return 'http://' + rawUrl.value;
125-
return rawUrl.value;
127+
if (rawUrl.value.startsWith('#') || HAS_PROTOCOL.test(rawUrl.value)) return rawUrl.value;
128+
return 'https://' + rawUrl.value;
126129
});
127130
128-
const validUrl = computed(() => {
129-
// anchors (starting with #) are always considered valid
130-
if (url.value.startsWith('#')) return true;
131-
132-
const urlSplit = url.value.split('.').filter(Boolean);
133-
return url.value.includes('.') && urlSplit.length > 1;
131+
const sanitizedUrl = computed(() => {
132+
if (!url.value) return null;
133+
return sanitizeHref(url.value);
134134
});
135135
136+
const validUrl = computed(() => sanitizedUrl.value !== null);
137+
136138
// --- CASE LOGIC ---
137139
const isEditing = computed(() => !isAnchor.value && !!getLinkHrefAtSelection());
138140
@@ -141,7 +143,9 @@ const isDisabled = computed(() => !validUrl.value);
141143
const isViewingMode = computed(() => props.editor?.options?.documentMode === 'viewing');
142144
143145
const openLink = () => {
144-
window.open(url.value, '_blank');
146+
const href = sanitizedUrl.value?.href;
147+
if (!href) return;
148+
window.open(href, '_blank');
145149
};
146150
147151
const updateFromEditor = () => {
@@ -189,10 +193,16 @@ const handleSubmit = () => {
189193
return;
190194
}
191195
192-
const finalText = text.value || url.value;
196+
const href = sanitizedUrl.value?.href;
197+
if (!href) {
198+
urlError.value = true;
199+
return;
200+
}
201+
202+
const finalText = text.value || href;
193203
194204
if (editor.commands?.toggleLink) {
195-
editor.commands.toggleLink({ href: url.value, text: finalText });
205+
editor.commands.toggleLink({ href, text: finalText });
196206
}
197207
198208
// Move cursor to end of link and refocus editor.

0 commit comments

Comments
 (0)