From 15435fc9a4e129dd3bf14f84c39c6c995937d53b Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 16 Nov 2021 22:13:04 +0000 Subject: [PATCH 1/2] Make webviews load locally Instead of using Microsoft's hosted endpoint. This also fixes issues when using code-server over http since some browsers refuse to load http content (like when using localhost) in https contexts (since the iframe was always https). --- src/vs/code/browser/workbench/workbench.ts | 19 ++++++++++++++ src/vs/server/webClientServer.ts | 29 +++++++++++++++++++++- src/vs/workbench/browser/web.main.ts | 8 +++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts index ac9c19e2597b2..53bcf719a1634 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts @@ -521,6 +521,25 @@ class WindowIndicator implements IWindowIndicator { // Finally create workbench create(document.body, { ...config, + /** + * Override relative URLs in the product configuration against the window + * location as necessary. Only paths that must be absolute need to be + * rewritten (for example the webview endpoint); the rest can be left + * relative (for example the update path). + * + * @author coder + */ + productConfiguration: { + ...config.productConfiguration, + // The webview endpoint contains variables in the format {{var}} so decode + // them as `new URI` will encode them. + webviewContentExternalBaseUrlTemplate: decodeURIComponent( + new URL( + config.productConfiguration?.webviewContentExternalBaseUrlTemplate ?? "", + window.location.toString(), // This works without toString() but TypeScript thinks otherwise. + ).toString(), + ), + }, developmentOptions: { logLevel: logLevel ? parseLogLevel(logLevel) : undefined, ...config.developmentOptions diff --git a/src/vs/server/webClientServer.ts b/src/vs/server/webClientServer.ts index 6c35a2c92ac55..1cb3b1bb39cad 100644 --- a/src/vs/server/webClientServer.ts +++ b/src/vs/server/webClientServer.ts @@ -103,6 +103,10 @@ export class WebClientServer { return serveFile(this._logService, req, res, join(APP_ROOT, 'resources', 'server', parsedPath.base)); } + if (parsedPath.dir.includes('/webview/') && parsedPath.ext) { + return this._handleWebview(req, res, parsedUrl); + } + if (parsedPath.base === this._environmentService.serviceWorkerFileName) { return serveFile(this._logService, req, res, this._environmentService.serviceWorkerPath, { 'Service-Worker-Allowed': pathPrefix, @@ -240,6 +244,28 @@ export class WebClientServer { return serveFile(this._logService, req, res, filePath, headers); } + /** + * Handle HTTP requests for /webview/* + * + * A unique path is required for every webview service worker. + */ + private async _handleWebview(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery): Promise { + const headers: Record = Object.create(null); + + // support paths that are uri-encoded (e.g. spaces => %20) + const normalizedPathname = decodeURIComponent(parsedUrl.pathname!); + + // Strip `/webview/{uuid}` from the path. + const relativeFilePath = normalize(normalizedPathname.split('/').splice(3).join('/')); + + const filePath = join(APP_ROOT, 'out/vs/workbench/contrib/webview/browser/pre', relativeFilePath); + if (!isEqualOrParent(filePath, APP_ROOT, !isLinux)) { + return this.serveError(req, res, 400, `Bad request.`, parsedUrl); + } + + return serveFile(this._logService, req, res, filePath, headers); + } + /** * Handle HTTP requests for / */ @@ -318,6 +344,7 @@ export class WebClientServer { logoutEndpointUrl: this.createRequestUrl(req, parsedUrl, '/logout').toString(), webEndpointUrl: this.createRequestUrl(req, parsedUrl, '/static').toString(), webEndpointUrlTemplate: this.createRequestUrl(req, parsedUrl, '/static').toString(), + webviewContentExternalBaseUrlTemplate: './webview/{{uuid}}/', updateUrl: this.createRequestUrl(req, parsedUrl, '/update/check').toString(), }, @@ -343,7 +370,7 @@ export class WebClientServer { // the sha is the same as in src/vs/workbench/services/extensions/worker/httpWebWorkerExtensionHostIframe.html `script-src 'self' 'unsafe-eval' ${this._getScriptCspHashes(data).join(' ')} 'sha256-cb2sg39EJV8ABaSNFfWu/ou8o1xVXYK7jp90oZ9vpcg=';`, 'child-src \'self\';', - `frame-src 'self' https://*.vscode-webview.net ${this._productService.webEndpointUrl || ''} data:;`, + `frame-src 'self' ${this._productService.webEndpointUrl || ''} data:;`, 'worker-src \'self\' data:;', 'style-src \'self\' \'unsafe-inline\';', 'connect-src \'self\' ws: wss: https:;', diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 7d458accca9bd..1b1cefb1ce4ba 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -102,13 +102,15 @@ class BrowserMain extends Disposable { // Startup const instantiationService = workbench.startup(); - /** @coder Initialize our own client-side additions. */ + /** + * Initialize our own client-side additions. + * + * @author Coder + */ if (!this.configuration.productConfiguration) { throw new Error('`productConfiguration` not present in workbench config'); } - const codeServerClientAdditions = this._register(instantiationService.createInstance(CodeServerClientAdditions, this.configuration.productConfiguration)); - await codeServerClientAdditions.startup(); // Window From ae3680bf25b5f7284b3cdaba023874e1c74b5024 Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 16 Nov 2021 22:46:05 +0000 Subject: [PATCH 2/2] Revert path checks in web server We cannot use `includes` because this causes problems with endpoints that may share portions of their paths. For example /webview/uuid/service-worker.js was colliding with the /service-worker.js but moving it before caused static assets that load from a directory called /webview to start failing. We could move static up as well but this may just cause other conflicts. The standard ways of handling base paths seem to be to either ask the user for the base path or require that a rewriting proxy be put in front of the application (which rewrites /base/path/my/path to /my/path before it ever gets to the application). code-server currently requires the latter and does not support the former but either way the code will look similar (in the former case we would just strip the base path first, maybe before calling `handle`). For reference, the proxy in the Coder product is a rewriting proxy. This also fixes issues when the web server is behind multiple Express endpoints. With the existing code /anything/works/ rather than just the endpoints you specify (/vscode and / in our case) which is unexpected. It also makes it possible to browse without a trailing slash, for example /vscode, although that is currently broken for other reasons. --- src/vs/server/webClientServer.ts | 62 +++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/vs/server/webClientServer.ts b/src/vs/server/webClientServer.ts index 1cb3b1bb39cad..d0511c31dd6c3 100644 --- a/src/vs/server/webClientServer.ts +++ b/src/vs/server/webClientServer.ts @@ -85,55 +85,59 @@ export class WebClientServer { private readonly _productService: IProductService ) { } - /** - * @coder Patched to handle an arbitrary path depth, such as in Coder Enterprise. - */ async handle(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery): Promise { try { const pathname = parsedUrl.pathname!; - const parsedPath = path.parse(pathname); - const pathPrefix = getPathPrefix(pathname); - if (['manifest.json', 'webmanifest.json'].includes(parsedPath.base)) { + /** + * Add a custom manifest. + * + * @author coder + */ + if (pathname === '/manifest.json' || pathname === '/webmanifest.json') { return this._handleManifest(req, res, parsedUrl); } - - if (['favicon.ico', 'code-192.png', 'code-512.png'].includes(parsedPath.base)) { + if (pathname === '/favicon.ico' || pathname === '/manifest.json' || pathname === '/code-192.png' || pathname === '/code-512.png') { // always serve icons/manifest, even without a token - return serveFile(this._logService, req, res, join(APP_ROOT, 'resources', 'server', parsedPath.base)); + return serveFile(this._logService, req, res, join(APP_ROOT, 'resources', 'server', pathname.substr(1))); } - - if (parsedPath.dir.includes('/webview/') && parsedPath.ext) { + /** + * Add an endpoint for self-hosting webviews. This must be unique per + * webview as the code relies on unique service workers. In our case we + * use /webview/{{uuid}}. + * + * @author coder + */ + if (/^\/webview\//.test(pathname)) { + // always serve webview requests, even without a token return this._handleWebview(req, res, parsedUrl); } - - if (parsedPath.base === this._environmentService.serviceWorkerFileName) { - return serveFile(this._logService, req, res, this._environmentService.serviceWorkerPath, { - 'Service-Worker-Allowed': pathPrefix, - }); - } - - if (parsedPath.dir.includes('/static/') && parsedPath.ext) { - parsedUrl.pathname = pathname.substring(pathname.indexOf('/static/')); + if (/^\/static\//.test(pathname)) { // always serve static requests, even without a token return this._handleStatic(req, res, parsedUrl); } - - if (parsedPath.base === 'callback') { + /** + * Move the service worker to the root. This makes the scope the root + * (otherwise we would need to include Service-Worker-Allowed). + * + * @author coder + */ + if (pathname === '/' + this._environmentService.serviceWorkerFileName) { + return serveFile(this._logService, req, res, this._environmentService.serviceWorkerPath); + } + if (pathname === '/') { + // the token handling is done inside the handler + return this._handleRoot(req, res, parsedUrl); + } + if (pathname === '/callback') { // callback support return this._handleCallback(req, res, parsedUrl); } - - if (parsedPath.base === 'fetch-callback') { + if (pathname === '/fetch-callback') { // callback fetch support return this._handleFetchCallback(req, res, parsedUrl); } - if (pathname.endsWith('/')) { - // the token handling is done inside the handler - return this._handleRoot(req, res, parsedUrl); - } - const message = `"${parsedUrl.pathname}" not found.`; const error = new Error(message); req.emit('error', error);