Skip to content
Merged
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 .bumpy/shy-rich-goat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inksane/core": patch
---

Add URL safety validation for image URLs
8 changes: 8 additions & 0 deletions packages/core/src/extensions/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EditorView, WidgetType } from "@codemirror/view";
import type { SyntaxNode } from "@lezer/common";
import { insertContent } from "../commands/index.ts";
import { resolveFromTo } from "../helpers/resolveFromTo.ts";
import { isSafeUrl } from "../helpers/isSafeUrl.ts";
import type { Extension, PosOrRange } from "../types/index.ts";

declare module "@inksane/core" {
Expand Down Expand Up @@ -34,6 +35,10 @@ class ImageWidget extends WidgetType {
}

toDOM(view: EditorView) {
if (!isSafeUrl(this.src)) {
return document.createElement("span");
}

const img = document.createElement("img");
img.className = "inksane-image";
img.src = this.src;
Expand Down Expand Up @@ -127,6 +132,9 @@ export const ImageExtension: Extension = {
insertImage:
(ctx) =>
({ src, alt, pos }) => {
if (!isSafeUrl(src)) {
return false;
}
const { from, to } = resolveFromTo(ctx.state, pos);
const content = `![${escapeBrackets(alt)}](${escapeParens(src)})`;
return insertContent(ctx)({ content, from, to });
Expand Down
11 changes: 1 addition & 10 deletions packages/core/src/extensions/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { WidgetType } from "@codemirror/view";
import type { SyntaxNode } from "@lezer/common";
import { insertContent } from "../commands/index.ts";
import { resolveFromTo } from "../helpers/resolveFromTo.ts";
import { isSafeUrl } from "../helpers/isSafeUrl.ts";
import type { Extension, PosOrRange } from "../types/index.ts";

declare module "@inksane/core" {
Expand Down Expand Up @@ -42,16 +43,6 @@ declare module "@inksane/core" {

const isAlreadyLink = (text: string) => /^\[([^\]]*)\]\(([^)]*)\)$/.exec(text);

/** Returns true for URLs with an allowed scheme (http, https, mailto) or no scheme (relative). */
const isSafeUrl = (url: string): boolean => {
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" || protocol === "mailto:";
} catch {
return !/^[a-z][a-z\d+.-]*:/i.test(url);
}
};

/** Renders a clickable external-link icon next to a link. */
class OpenLinkWidget extends WidgetType {
private url: string;
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/helpers/isSafeUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vite-plus/test";
import { isSafeUrl } from "./isSafeUrl.ts";

describe("isSafeUrl", () => {
it("allows http URLs", () => {
expect(isSafeUrl("http://example.com")).toBe(true);
});

it("allows https URLs", () => {
expect(isSafeUrl("https://example.com")).toBe(true);
});

it("allows mailto URLs", () => {
expect(isSafeUrl("mailto:user@example.com")).toBe(true);
});

it("allows relative paths", () => {
expect(isSafeUrl("/images/photo.png")).toBe(true);
});

it("allows relative paths without leading slash", () => {
expect(isSafeUrl("images/photo.png")).toBe(true);
});

it("allows query parameters", () => {
expect(isSafeUrl("https://example.com?foo=bar")).toBe(true);
});

it("allows hash fragments", () => {
expect(isSafeUrl("https://example.com#section")).toBe(true);
});

it("rejects javascript URLs", () => {
expect(isSafeUrl("javascript:alert(1)")).toBe(false);
});

it("rejects data URLs", () => {
expect(isSafeUrl("data:text/html,<script>alert(1)</script>")).toBe(false);
});

it("rejects vbscript URLs", () => {
expect(isSafeUrl("vbscript:MsgBox(1)")).toBe(false);
});

it("rejects file URLs", () => {
expect(isSafeUrl("file:///etc/passwd")).toBe(false);
});

it("rejects ftp URLs", () => {
expect(isSafeUrl("ftp://example.com")).toBe(false);
});

it("rejects custom protocol URLs", () => {
expect(isSafeUrl("myapp://deep/link")).toBe(false);
});

it("rejects uppercase JAVASCRIPT URLs", () => {
expect(isSafeUrl("JAVASCRIPT:alert(1)")).toBe(false);
});

it("rejects mixed case JsCrIpT URLs", () => {
expect(isSafeUrl("JsCrIpT:alert(1)")).toBe(false);
});

it("allows URLs with credentials", () => {
expect(isSafeUrl("https://user:pass@example.com")).toBe(true);
});

it("allows URLs with port", () => {
expect(isSafeUrl("https://example.com:8080")).toBe(true);
});

it("allows empty string", () => {
expect(isSafeUrl("")).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/core/src/helpers/isSafeUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Checks whether a URL is safe to use by validating its protocol.
*
* A URL is considered safe if it uses one of the following schemes:
* - `http:` — standard HTTP
* - `https:` — secure HTTP
* - `mailto:` — email links
*
* Relative URLs (no scheme) are also considered safe.
*
* @param url - The URL string to validate.
* @returns `true` if the URL is safe, `false` otherwise.
*
* @example
* ```ts
* isSafeUrl("https://example.com"); // true
* isSafeUrl("javascript:alert(1)"); // false
* isSafeUrl("/relative/path"); // true
* ```
*/
export const isSafeUrl = (url: string): boolean => {
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" || protocol === "mailto:";
} catch {
return !/^[a-z][a-z\d+.-]*:/i.test(url);
}
};
Loading