Repository navigation
fix(web-ui): resolve the base path at runtime for reverse proxies #6096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,7 +4,24 @@ | |||||
| <meta charset="UTF-8" /> | ||||||
| <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||||
| <base href="/" /> | ||||||
| <script> | ||||||
| // Resolve the web UI's base path from the current URL, so the UI also works when a reverse proxy | ||||||
| // serves it under a sub-path (for example https://example.com/sunshine/). Assets, API requests and | ||||||
| // vue-router resolve against this <base>. Keep the route names in sync with the routes in router.js. | ||||||
| (function () { | ||||||
| var routes = ['apps', 'clients', 'config', 'featured', 'logout', 'password', 'pin', 'troubleshooting', 'welcome']; | ||||||
| var segments = location.pathname.split('/'); | ||||||
| // Drop the final path segment; it is empty when the path ends with a slash. | ||||||
| segments.pop(); | ||||||
| // A route visited with a trailing slash (for example /logout/) leaves its name as the last segment. | ||||||
| if (segments.length > 1 && routes.indexOf(segments[segments.length - 1]) !== -1) { | ||||||
| segments.pop(); | ||||||
| } | ||||||
| var base = document.createElement('base'); | ||||||
| base.href = segments.join('/') + '/'; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Keep the generated base on the document's origin If the pathname starts with repeated slashes, this assignment can produce a network-path reference rather than a root-relative path. Relative built scripts, styles and API URLs then resolve against a different origin. I verified the inline script's output and URL resolution, not an end-to-end exploit or the deployed server/proxy's normalization. The previous fixed Ensure the output has exactly one leading slash before assigning it. This focused guard preserves the ordinary
Suggested change
Also add a regression in Review completed by Codex | Model: unavailable | Thinking: unavailable |
||||||
| document.head.appendChild(base); | ||||||
| })(); | ||||||
| </script> | ||||||
| <title>Sunshine</title> | ||||||
| <link rel="icon" type="image/x-icon" href="/images/sunshine.ico" /> | ||||||
| </head> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { readdirSync, readFileSync } from 'node:fs' | ||
| import { dirname, join, resolve } from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { routes } from '../../src_assets/common/assets/web/router.js' | ||
|
|
||
| const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../src_assets/common/assets/web') | ||
| const indexHtml = readFileSync(join(webRoot, 'index.html'), 'utf8') | ||
|
|
||
| /** | ||
| * @brief Extract the classic inline script that resolves the base path from index.html. | ||
| * | ||
| * @return {string} Inline script source. | ||
| */ | ||
| function baseScriptSource() { | ||
| const match = /<script>([\s\S]*?)<\/script>/.exec(indexHtml) | ||
| expect(match).not.toBeNull() | ||
| return match[1] | ||
| } | ||
|
|
||
| /** | ||
| * @brief Run the index.html base path script for a URL path in an isolated document. | ||
| * | ||
| * @param {string} pathname Path the browser requested. | ||
| * @return {string|null} The href attribute of the inserted base element. | ||
| */ | ||
| function resolveBase(pathname) { | ||
| const doc = document.implementation.createHTMLDocument('Sunshine') | ||
| new Function('location', 'document', baseScriptSource())({ pathname }, doc) | ||
| return doc.querySelector('base')?.getAttribute('href') ?? null | ||
| } | ||
|
|
||
| /** | ||
| * @brief List web UI source files that are bundled by Vite. | ||
| * | ||
| * @param {string} dir Directory to scan. | ||
| * @return {string[]} Absolute paths of .js and .vue files outside public assets. | ||
| */ | ||
| function sourceFiles(dir) { | ||
| return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { | ||
| const path = join(dir, entry.name) | ||
| if (entry.isDirectory()) { | ||
| return entry.name === 'public' ? [] : sourceFiles(path) | ||
| } | ||
| return /\.(js|vue)$/.test(entry.name) ? [path] : [] | ||
| }) | ||
| } | ||
|
|
||
| describe('web UI base path', () => { | ||
| it.each([ | ||
| ['/', '/'], | ||
| ['/config', '/'], | ||
| ['/logout/', '/'], | ||
| ['/unknown', '/'], | ||
| ['/apps/unknown', '/'], | ||
| ['/sunshine/', '/sunshine/'], | ||
| ['/sunshine/config', '/sunshine/'], | ||
| ['/sunshine/troubleshooting/', '/sunshine/'], | ||
| ['/sunshine/unknown', '/sunshine/'], | ||
| ['/proxy/sunshine/pin', '/proxy/sunshine/'], | ||
| ])('resolves %s to base %s', (pathname, expected) => { | ||
| expect(resolveBase(pathname)).toBe(expected) | ||
| }) | ||
|
|
||
| it('knows every top-level route defined in the router', () => { | ||
| const routeNames = routes | ||
| .map(route => route.path.split('/')[1]) | ||
| .filter(name => name && !name.startsWith(':')) | ||
| const script = baseScriptSource() | ||
| const scriptRoutes = JSON.parse(/var routes = (\[[^\]]*\]);/.exec(script)[1].replaceAll("'", '"')) | ||
|
|
||
| expect(scriptRoutes.toSorted()).toEqual(routeNames.toSorted()) | ||
| }) | ||
|
|
||
| it('does not hard-code a root base element', () => { | ||
| expect(indexHtml).not.toMatch(/<base\s/) | ||
| }) | ||
|
|
||
| // Vite rewrites public asset URLs such as /images/... relative to the bundle, but not API request URLs. | ||
| it('does not request the API from the domain root', () => { | ||
| const rootRelative = /["'`]\/api\// | ||
| const offenders = sourceFiles(webRoot) | ||
| .flatMap(file => readFileSync(file, 'utf8') | ||
| .split('\n') | ||
| .filter(line => rootRelative.test(line)) | ||
| .map(line => `${file}: ${line.trim()}`)) | ||
|
|
||
| expect(offenders).toEqual([]) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Do not discard a proxy prefix just because it matches a route
For a proxy mounted at
/apps/, both/apps/and/apps/configproduce base/; for a proxy mounted at/proxy/config/, both/proxy/config/and/proxy/config/appsproduce/proxy/. Assets and API calls therefore leave the configured mount and the UI still fails to load. I reproduced these outputs with this inline script. These are valid mount prefixes and known routes, not the documented unknown-path limitation.The same pathname can represent either a direct route or a proxy mount, so a route-name heuristic cannot distinguish every case. Please provide an explicit base-prefix override (or another unambiguous source of the mount prefix) rather than stripping a matching segment unconditionally. Once that input is available, extend
tests/web/base_path.test.jsto cover configured/apps/and/proxy/config/mounts at their roots and at/configor/appsrespectively, while retaining direct/apps/and/logout/coverage. Assert both the selected base and that an asset/API URL stays under it. A directly applicable one-line replacement would be misleading here because the required prefix input is not present yet.Review completed by Codex | Model: unavailable | Thinking: unavailable