diff --git a/test/unit/clitests.json b/test/unit/clitests.json index 744fb63a8..3ae55b789 100644 --- a/test/unit/clitests.json +++ b/test/unit/clitests.json @@ -42,6 +42,7 @@ "pdf_find_controller_spec.js", "pdf_find_utils_spec.js", "pdf_history_spec.js", + "pdf_link_service_spec.js", "pdf_spec.js", "pdf_viewer.component_spec.js", "pdf_viewer_spec.js", diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index 4f04beb73..e151d7ebb 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -85,6 +85,7 @@ async function initializePDFJS(callback) { "pdfjs-test/unit/pdf_find_controller_spec.js", "pdfjs-test/unit/pdf_find_utils_spec.js", "pdfjs-test/unit/pdf_history_spec.js", + "pdfjs-test/unit/pdf_link_service_spec.js", "pdfjs-test/unit/pdf_spec.js", "pdfjs-test/unit/pdf_viewer.component_spec.js", "pdfjs-test/unit/pdf_viewer_spec.js", diff --git a/test/unit/pdf_link_service_spec.js b/test/unit/pdf_link_service_spec.js new file mode 100644 index 000000000..f13794b99 --- /dev/null +++ b/test/unit/pdf_link_service_spec.js @@ -0,0 +1,115 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PDFLinkService } from "../../web/pdf_link_service.js"; + +describe("PDFLinkService", function () { + describe("addLinkAttributes", function () { + function createLinkService({ externalLinkEnabled = true } = {}) { + const linkService = new PDFLinkService(); + linkService.externalLinkEnabled = externalLinkEnabled; + return linkService; + } + + // Use a plain object instead of a real DOM element so tests run in Node.js. + function createLink() { + return { href: "", title: "", target: "", rel: "" }; + } + + it("sets href and title for a plain URL", function () { + const linkService = createLinkService(); + const link = createLink(); + + linkService.addLinkAttributes(link, "https://example.com/path"); + + expect(link.href).toEqual("https://example.com/path"); + expect(link.title).toEqual("https://example.com/path"); + }); + + it("strips userinfo from the title to prevent hostname spoofing", function () { + const linkService = createLinkService(); + const link = createLink(); + + // URL with username that looks like a trusted domain. + linkService.addLinkAttributes( + link, + "https://trusted.example@attacker.example/path" + ); + + expect(link.href).toEqual( + "https://trusted.example@attacker.example/path" + ); + expect(link.title).toEqual("https://attacker.example/path"); + }); + + it("strips username and password from the title", function () { + const linkService = createLinkService(); + const link = createLink(); + + linkService.addLinkAttributes( + link, + "https://user:password@example.com/path" + ); + + expect(link.href).toEqual("https://user:password@example.com/path"); + expect(link.title).toEqual("https://example.com/path"); + }); + + it("strips only username (no password) from the title", function () { + const linkService = createLinkService(); + const link = createLink(); + + linkService.addLinkAttributes(link, "https://user@example.com/page"); + + expect(link.href).toEqual("https://user@example.com/page"); + expect(link.title).toEqual("https://example.com/page"); + }); + + it("does not alter the title when there is no userinfo", function () { + const linkService = createLinkService(); + const link = createLink(); + + linkService.addLinkAttributes( + link, + "https://example.com/path?q=1#anchor" + ); + + expect(link.title).toEqual("https://example.com/path?q=1#anchor"); + }); + + it("disables the link and prefixes the title when externalLinkEnabled is false", function () { + const linkService = createLinkService({ externalLinkEnabled: false }); + const link = createLink(); + + linkService.addLinkAttributes(link, "https://example.com/path"); + + expect(link.href).toEqual(""); + expect(link.title).toEqual("Disabled: https://example.com/path"); + }); + + it("strips userinfo from the title even when the link is disabled", function () { + const linkService = createLinkService({ externalLinkEnabled: false }); + const link = createLink(); + + linkService.addLinkAttributes( + link, + "https://trusted.example@attacker.example/path" + ); + + expect(link.href).toEqual(""); + expect(link.title).toEqual("Disabled: https://attacker.example/path"); + }); + }); +}); diff --git a/web/pdf_link_service.js b/web/pdf_link_service.js index cefced69c..2b592d62b 100644 --- a/web/pdf_link_service.js +++ b/web/pdf_link_service.js @@ -266,11 +266,21 @@ class PDFLinkService { const target = newWindow ? LinkTarget.BLANK : this.externalLinkTarget, rel = this.externalLinkRel; + // Strip userinfo (user:password@) from URLs used for display, to prevent + // phishing via hostname-spoofing (e.g. https://trusted.example@attacker.example/). + let displayUrl = url; + const parsedUrl = URL.parse(url); + if (parsedUrl?.username || parsedUrl?.password) { + parsedUrl.username = parsedUrl.password = ""; + displayUrl = parsedUrl.href; + } + if (this.externalLinkEnabled) { - link.href = link.title = url; + link.href = url; + link.title = displayUrl; } else { link.href = ""; - link.title = `Disabled: ${url}`; + link.title = `Disabled: ${displayUrl}`; link.onclick = () => false; }