Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Code v99.99.999

- `--idle-timeout-seconds` was only validated when passed as `--idle-timeout-seconds=<value>`;
values of 60 or less passed as `--idle-timeout-seconds <value>` were silently accepted.
- Load local styles configured with `markdown.styles` in Firefox 149 and later.

## [4.138.0](https://github.com/coder/code-server/releases/tag/v4.138.0) - 2026-09-19

Expand Down
4 changes: 2 additions & 2 deletions patches/csp-hashes.diff
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index
<meta charset="UTF-8">

<meta http-equiv="Content-Security-Policy"
- content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">
+ content="default-src 'none'; script-src 'sha256-24QqbpzqcqSyIGvg/ZwFRy5pPlhmVBa8CkUREEIUnBw=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">
- content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; connect-src 'self' https://*.vscode-cdn.net; frame-src 'self'; style-src 'unsafe-inline';">
+ content="default-src 'none'; script-src 'sha256-u3FINRB6N9ZtXZVeE1TdGILlPsfDesEgZJhO81bfwi0=' 'self'; connect-src 'self' https://*.vscode-cdn.net; frame-src 'self'; style-src 'unsafe-inline';">

<!-- Disable pinch zooming -->
<meta name="viewport"
Expand Down
160 changes: 159 additions & 1 deletion patches/webview.diff
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ request is not cross-origin.
There is also an origin check we bypass (this seems to be related to how the
webview host is separate by default but we serve on the same host).

To test, open a few types of webviews (images, markdown, extension details, etc).
Firefox 149 and later skip service worker interception for cross-origin
stylesheet requests. Load these stylesheets from the controlled outer webview
frame and apply them to the content as constructed stylesheets.

To test, configure a local stylesheet with markdown.styles, then open a Markdown
preview in Firefox and verify the custom stylesheet is applied. Also open a few
other types of webviews (images, extension details, etc).

parentOriginHash changes

Expand Down Expand Up @@ -53,6 +59,15 @@ Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index
===================================================================
--- code-server.orig/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index.html
+++ code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index.html
@@ -5,7 +5,7 @@
<meta charset="UTF-8">

<meta http-equiv="Content-Security-Policy"
- content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">
+ content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; connect-src 'self' https://*.vscode-cdn.net; frame-src 'self'; style-src 'unsafe-inline';">

<!-- Disable pinch zooming -->
<meta name="viewport"
@@ -253,7 +253,7 @@
}

Expand All @@ -75,6 +90,149 @@ Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index
if (!crypto.subtle) {
// cannot validate, not running in a secure context
throw new Error(`'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).`);
@@ -841,13 +847,69 @@
}

/**
- * @param {import('../webviewMessages').UpdateContentEvent} data
- * @return {string}
+ * Firefox 149+ skips service worker interception for cross-origin stylesheet
+ * requests. Fetch these stylesheets from the controlled outer frame instead.
+ *
+ * @param {Document} contentDocument
+ * @returns {Promise<Array<{ href: string, css: string, media: string, disabled: boolean }>>}
*/
- function toContentHtml(data) {
+ async function loadFirefoxStylesheets(contentDocument) {
+ if (!isFirefox) {
+ return [];
+ }
+
+ const resourceBaseAuthority = searchParams.get('vscode-resource-base-authority');
+ if (!resourceBaseAuthority) {
+ return [];
+ }
+
+ const stylesheets = [];
+ for (const link of contentDocument.querySelectorAll('link')) {
+ if (!link.relList.contains('stylesheet')) {
+ continue;
+ }
+
+ let resourceUrl;
+ try {
+ resourceUrl = new URL(link.href, link.baseURI);
+ } catch {
+ continue;
+ }
+
+ if (resourceUrl.protocol !== 'https:' || !resourceUrl.hostname.endsWith('.' + resourceBaseAuthority)) {
+ continue;
+ }
+
+ try {
+ const response = await fetch(resourceUrl);
+ if (!response.ok) {
+ continue;
+ }
+
+ stylesheets.push({
+ href: resourceUrl.toString(),
+ css: await response.text(),
+ media: link.media,
+ disabled: link.disabled,
+ });
+ link.remove();
+ } catch {
+ // Leave the link in place so the webview reports the original load failure.
+ }
+ }
+
+ return stylesheets;
+ }
+
+ /**
+ * @param {import('../webviewMessages').UpdateContentEvent} data
+ * @returns {Promise<{ html: string, stylesheets: Array<{ href: string, css: string, media: string, disabled: boolean }> }>}
+ */
+ async function toContentHtml(data) {
const options = data.options;
const text = data.contents;
const newDocument = new DOMParser().parseFromString(text, 'text/html');
+ const stylesheets = await loadFirefoxStylesheets(newDocument);

newDocument.querySelectorAll('a').forEach(a => {
if (!a.title) {
@@ -904,7 +966,10 @@

// set DOCTYPE for newDocument explicitly as DOMParser.parseFromString strips it off
// and DOCTYPE is needed in the iframe to ensure that the user agent stylesheet is correctly overridden
- return '<!DOCTYPE html>\n' + newDocument.documentElement.outerHTML;
+ return {
+ html: '<!DOCTYPE html>\n' + newDocument.documentElement.outerHTML,
+ stylesheets,
+ };
}

// Also forward events before the contents of the webview have loaded
@@ -979,7 +1044,10 @@
}

const options = data.options;
- const newDocument = toContentHtml(data);
+ const { html: newDocument, stylesheets } = await toContentHtml(data);
+ if (currentUpdateId !== updateId) {
+ return;
+ }

const initialStyleVersion = styleVersion;

@@ -1055,15 +1123,44 @@
/**
* @param {Document} contentDocument
*/
+ async function applyFirefoxStylesheets(contentDocument) {
+ if (!stylesheets.length || !contentDocument.defaultView) {
+ return;
+ }
+
+ /** @type {CSSStyleSheet[]} */
+ const sheets = [];
+ for (const stylesheet of stylesheets) {
+ const sheet = new contentDocument.defaultView.CSSStyleSheet({
+ baseURL: stylesheet.href,
+ media: stylesheet.media,
+ disabled: stylesheet.disabled,
+ });
+ try {
+ await sheet.replace(stylesheet.css);
+ } catch (error) {
+ console.error(`Failed to apply stylesheet '${stylesheet.href}': ${error}`);
+ continue;
+ }
+ sheets.push(sheet);
+ }
+
+ contentDocument.adoptedStyleSheets = [...contentDocument.adoptedStyleSheets, ...sheets];
+ }
+
+ /**
+ * @param {Document} contentDocument
+ */
function onFrameLoaded(contentDocument) {
perfMark('content/innerFrameLoaded')

// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=978325
- setTimeout(() => {
+ setTimeout(async () => {
contentDocument.open();
contentDocument.write(newDocument);
contentDocument.close();
hookupOnLoadHandlers(newFrame);
+ await applyFirefoxStylesheets(contentDocument);
perfMark('content/wroteInnerContent')

if (initialStyleVersion !== styleVersion) {
Index: code-server/lib/vscode/src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html
===================================================================
--- code-server.orig/lib/vscode/src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html
Expand Down
25 changes: 20 additions & 5 deletions test/e2e/webview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@ import * as path from "path"
import { describe, test, expect } from "./baseFixture"

describe("Webviews", ["--disable-workspace-trust"], {}, () => {
test("should preview a Markdown file", async ({ codeServerPage }) => {
// Create Markdown file
test("should preview a Markdown file with custom styles", async ({ codeServerPage }) => {
const heading = "Hello world"
const dir = await codeServerPage.workspaceDir
const file = path.join(dir, "text.md")
const style = path.join(dir, "test.css")
const settingsPath = path.join(dir, "User/settings.json")
const settings = JSON.parse(await fs.readFile(settingsPath, "utf8"))

settings["markdown.styles"] = ["test.css"]
await fs.writeFile(settingsPath, JSON.stringify(settings))
await fs.writeFile(style, "h1 { color: rgb(1, 2, 3); }")
await fs.writeFile(file, `# ${heading}`)

// Reload so the workbench reads the updated user settings before rendering
// the Markdown preview.
await codeServerPage.page.reload()
await codeServerPage.reloadUntilEditorIsReady()
await codeServerPage.openFile(file)

// Open Preview
Expand All @@ -18,8 +29,12 @@ describe("Webviews", ["--disable-workspace-trust"], {}, () => {

// It's an iframe within an iframe
// so we have to do .frameLocator twice
await expect(
codeServerPage.page.frameLocator("iframe.webview.ready").frameLocator("#active-frame").getByText("Hello world"),
).toBeVisible()
const previewHeading = codeServerPage.page
.frameLocator("iframe.webview.ready")
.frameLocator("#active-frame")
.getByText(heading)

await expect(previewHeading).toBeVisible()
await expect(previewHeading).toHaveCSS("color", "rgb(1, 2, 3)")
})
})
Loading