mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Strip unsafe URLs from Markdown links
This commit is contained in:
@@ -1994,6 +1994,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
* Web app: Smooth transitions and loading animation, remove flickering
|
* Web app: Smooth transitions and loading animation, remove flickering
|
||||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||||
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
||||||
|
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||||
|
|
||||||
### ntfy Android v1.25.x (UNRELEASED)
|
### ntfy Android v1.25.x (UNRELEASED)
|
||||||
|
|
||||||
|
|||||||
@@ -444,6 +444,17 @@ export const updateFavicon = async (count) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Matches a URL whose scheme is NOT in the safe list (https, mailto, ...). A scheme
|
||||||
|
// (RFC 3986: [a-z][a-z0-9+.-]*) can't contain a slash, query, or hash, so a colon later
|
||||||
|
// in the path/query (e.g. foo?x=a:b) is never mistaken for a protocol separator. Relative
|
||||||
|
// URLs have no scheme and never match. This strips javascript:/data:/vbscript:/... so React
|
||||||
|
// never has to block a javascript: URL at render time -- which it does loudly, throwing an
|
||||||
|
// uncaught error.
|
||||||
|
const unsafeUrlProtocol = /^\s*(?!(?:https?|ftps?|ircs?|mailto|xmpp|tel):)[a-z][a-z0-9+.-]*:/i;
|
||||||
|
|
||||||
|
// Returns the URL unchanged if it uses a safe protocol or is relative, otherwise "".
|
||||||
|
export const sanitizeUrl = (url) => (url && unsafeUrlProtocol.test(url) ? "" : url || "");
|
||||||
|
|
||||||
export const copyToClipboard = (text) => {
|
export const copyToClipboard = (text) => {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
return navigator.clipboard.writeText(text);
|
return navigator.clipboard.writeText(text);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
withBearerAuth,
|
withBearerAuth,
|
||||||
maybeWithAuth,
|
maybeWithAuth,
|
||||||
splitNoEmpty,
|
splitNoEmpty,
|
||||||
|
sanitizeUrl,
|
||||||
hashCode,
|
hashCode,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -216,3 +217,40 @@ describe("date/time formatting", () => {
|
|||||||
expect(formatDateTime(ts, DATE_FORMAT.ISO8601, TIME_FORMAT.H12)).toBe("2026-03-08 14:30");
|
expect(formatDateTime(ts, DATE_FORMAT.ISO8601, TIME_FORMAT.H12)).toBe("2026-03-08 14:30");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sanitizeUrl", () => {
|
||||||
|
it("keeps safe absolute URLs", () => {
|
||||||
|
expect(sanitizeUrl("https://ntfy.sh")).toBe("https://ntfy.sh");
|
||||||
|
expect(sanitizeUrl("http://example.com/foo?bar=1")).toBe("http://example.com/foo?bar=1");
|
||||||
|
expect(sanitizeUrl("mailto:phil@ntfy.sh")).toBe("mailto:phil@ntfy.sh");
|
||||||
|
expect(sanitizeUrl("ftp://ftp.example.com/file.txt")).toBe("ftp://ftp.example.com/file.txt");
|
||||||
|
expect(sanitizeUrl("ftps://ftp.example.com/file.txt")).toBe("ftps://ftp.example.com/file.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps relative URLs, fragments and query strings", () => {
|
||||||
|
expect(sanitizeUrl("/docs")).toBe("/docs");
|
||||||
|
expect(sanitizeUrl("./foo")).toBe("./foo");
|
||||||
|
expect(sanitizeUrl("#section")).toBe("#section");
|
||||||
|
expect(sanitizeUrl("?q=1")).toBe("?q=1");
|
||||||
|
expect(sanitizeUrl("foo/bar")).toBe("foo/bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a colon after a slash/query/hash as a protocol", () => {
|
||||||
|
expect(sanitizeUrl("/path:with:colons")).toBe("/path:with:colons");
|
||||||
|
expect(sanitizeUrl("foo?x=a:b")).toBe("foo?x=a:b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips dangerous protocols", () => {
|
||||||
|
expect(sanitizeUrl("javascript:alert(document.domain)")).toBe("");
|
||||||
|
expect(sanitizeUrl("JavaScript:alert(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl(" javascript:alert(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty and nullish input", () => {
|
||||||
|
expect(sanitizeUrl("")).toBe("");
|
||||||
|
expect(sanitizeUrl(undefined)).toBe("");
|
||||||
|
expect(sanitizeUrl(null)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import * as React from "react";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRemark } from "react-remark";
|
import { useRemark } from "react-remark";
|
||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
|
import { sanitizeUrl } from "../app/utils";
|
||||||
|
|
||||||
|
// Strip unsafe URL protocols (javascript:, data:, ...) from links and images. Without
|
||||||
|
// this, React blocks javascript: URLs at render time and throws an uncaught "React has
|
||||||
|
// blocked a javascript: URL" error.
|
||||||
|
const SafeLink = ({ href, children, ...props }) => (
|
||||||
|
<a href={sanitizeUrl(href)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SafeImage = ({ src, alt, ...props }) => <img src={sanitizeUrl(src)} alt={alt} {...props} />;
|
||||||
|
|
||||||
const MarkdownContainer = styled("div")`
|
const MarkdownContainer = styled("div")`
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -50,7 +62,9 @@ const MarkdownContainer = styled("div")`
|
|||||||
// dependency stack) is heavy, so this component lives in its own module and is loaded
|
// dependency stack) is heavy, so this component lives in its own module and is loaded
|
||||||
// lazily by Notifications.jsx -- it only ships when a markdown message is actually shown.
|
// lazily by Notifications.jsx -- it only ships when a markdown message is actually shown.
|
||||||
const MarkdownContent = ({ content }) => {
|
const MarkdownContent = ({ content }) => {
|
||||||
const [reactContent, setMarkdownSource] = useRemark();
|
const [reactContent, setMarkdownSource] = useRemark({
|
||||||
|
rehypeReactOptions: { components: { a: SafeLink, img: SafeImage } },
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMarkdownSource(content);
|
setMarkdownSource(content);
|
||||||
|
|||||||
Reference in New Issue
Block a user