-
-
Notifications
You must be signed in to change notification settings - Fork 607
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(vite-plugin): add CORS Proxy (#357)
Closes: #338
- Loading branch information
1 parent
fbeb392
commit a3c5822
Showing
8 changed files
with
617 additions
and
9 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import {beforeEach, describe, expect, it, vi} from 'vitest'; | ||
import {isProxyEnabled, viaProxy} from './proxyUtils'; | ||
const windowMock = { | ||
location: { | ||
toString: () => 'https://mockhostname:1234', | ||
}, | ||
}; | ||
|
||
function proxy(str: string) { | ||
return '/cors-proxy/' + encodeURIComponent(str); | ||
} | ||
|
||
describe('proxyUtils', () => { | ||
describe('isProxyEnabled()', () => { | ||
it('should default to false without import.meta set', () => { | ||
import.meta.env['VITE_MC_PROXY_ENABLED'] = undefined; | ||
expect(isProxyEnabled()).toStrictEqual(false); | ||
}), | ||
it("should return true if 'true' is set for VITE_MC_PROXY_ENABLED", () => { | ||
import.meta.env['VITE_MC_PROXY_ENABLED'] = 'true'; | ||
expect(isProxyEnabled()).toStrictEqual(true); | ||
}), | ||
it("should return false if 'false' is set for VITE_MC_PROXY_ENABLED", () => { | ||
import.meta.env['VITE_MC_PROXY_ENABLED'] = 'false'; | ||
expect(isProxyEnabled()).toStrictEqual(false); | ||
}); | ||
}); | ||
|
||
describe('viaProxy()', () => { | ||
it('should not Proxy if VITE_MC_PROXY_ENABLED is not set', () => { | ||
import.meta.env['VITE_MC_PROXY_ENABLED'] = undefined; | ||
const input = 'https://via.placeholder.com/300.png/09f/fff'; | ||
expect(viaProxy(input)).toStrictEqual(input); | ||
}), | ||
it("should not Proxy if VITE_MC_PROXY_ENABLED is set to 'false'", () => { | ||
import.meta.env['VITE_MC_PROXY_ENABLED'] = 'false'; | ||
const input = 'https://via.placeholder.com/300.png/09f/fff'; | ||
expect(viaProxy(input)).toStrictEqual(input); | ||
}), | ||
describe('VITE_MC_PROXY_ENABLED is enabled', () => { | ||
beforeEach(() => { | ||
import.meta.env.VITE_MC_PROXY_ENABLED = 'true'; | ||
import.meta.env.VITE_MC_PROXY_ALLOW_LIST = undefined; | ||
vi.stubGlobal('window', windowMock); | ||
}); | ||
const input = 'https://via.placeholder.com/300.png/09f/fff'; | ||
const proxiedInput = proxy(input); | ||
|
||
it('should proxy if VITE_MC_PROXY_ALLOW_LIST is not set', () => { | ||
delete import.meta.env['VITE_MC_PROXY_ALLOW_LIST']; | ||
expect(viaProxy(input)).toStrictEqual(proxiedInput); | ||
}); | ||
it('should not proxy if the host is not in the allow list', () => { | ||
import.meta.env['VITE_MC_PROXY_ALLOW_LIST'] = JSON.stringify([ | ||
'google.com', | ||
]); | ||
const x = viaProxy(input); | ||
expect(x).toStrictEqual(input); | ||
}); | ||
it('should proxy if VITE_MC_PROXY_ALLOW_LIST is an empty list', () => { | ||
import.meta.env['VITE_MC_PROXY_ALLOW_LIST'] = JSON.stringify([]); | ||
expect(viaProxy(input)).toStrictEqual(proxiedInput); | ||
}); | ||
it('should proxy if the host is on the allow list', () => { | ||
(import.meta.env['VITE_MC_PROXY_ALLOW_LIST'] = JSON.stringify([ | ||
'via.placeholder.com', | ||
])), | ||
expect(viaProxy(input)).toStrictEqual(proxiedInput); | ||
}); | ||
it('should not proxy if the host is the same as the server', () => { | ||
import.meta.env['VITE_MC_PROXY_ALLOW_LIST'] = JSON.stringify([]); | ||
const input = windowMock.location.toString() + '/some/example.png'; | ||
expect(viaProxy(input)).toStrictEqual(input); | ||
}); | ||
}), | ||
describe('Protocols', () => { | ||
beforeEach(() => { | ||
import.meta.env.VITE_MC_PROXY_ENABLED = 'true'; | ||
import.meta.env.VITE_MC_PROXY_ALLOW_LIST = JSON.stringify([]); | ||
vi.stubGlobal('window', windowMock); | ||
}); | ||
|
||
it('should proxy if the request protocol is http and https', () => { | ||
const suffix = '://via.placeholder.com/300.png/09f/fff'; | ||
const httpReq = 'http' + suffix; | ||
const httpsReq = 'https' + suffix; | ||
|
||
expect(viaProxy(httpReq)).toStrictEqual(proxy(httpReq)); | ||
expect(viaProxy(httpsReq)).toStrictEqual(proxy(httpsReq)); | ||
}); | ||
it('should not proxy other protocols like data:', () => { | ||
const dataBlob = | ||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2PYOO36fwAHOAMeASY22QAAAABJRU5ErkJggg=='; | ||
expect(viaProxy(dataBlob)).toStrictEqual(dataBlob); | ||
}); | ||
}), | ||
it('should not rewrite an already proxied request', () => { | ||
const raw = 'https://via.placeholder.com/300.png/09f/fff'; | ||
|
||
expect(viaProxy(raw)).not.toStrictEqual(raw); | ||
expect(viaProxy(viaProxy(raw))).toStrictEqual(viaProxy(raw)); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
/** | ||
* Utility to redirect remote sources via Proxy | ||
* | ||
* This utility is used to rewrite a request to be routed through | ||
* the Proxy instead. | ||
*/ | ||
|
||
import {useLogger} from './useProject'; | ||
|
||
/** | ||
* Route the given url through a local proxy. | ||
* | ||
* @example | ||
* This rewrites a remote url like `https://via.placeholder.com/300.png/09f/fff` | ||
* into a URI-Component-Encoded string like | ||
* `/cors-proxy/https%3A%2F%2Fvia.placeholder.com%2F300.png%2F09f%2Ffff` | ||
*/ | ||
export function viaProxy(url: string) { | ||
if (!isProxyEnabled()) { | ||
// Proxy is disabled, so we just pass as-is. | ||
return url; | ||
} | ||
|
||
if (url.startsWith('/cors-proxy/')) { | ||
// Already proxied, return as-is | ||
return url; | ||
} | ||
|
||
// window.location.hostname is being passed here to ensure that | ||
// this does not throw an Error for same-origin requests | ||
// e.g. /some/image -> localhost:9000/some/image | ||
const selfUrl = new URL(window.location.toString()); | ||
// inside a try-catch in case the URL cannot be understood | ||
try { | ||
const expandedUrl = new URL(url, selfUrl); | ||
if (!expandedUrl.protocol.startsWith('http')) { | ||
// this is probably some embedded image (e.g. image/png;base64). | ||
// don't touch and pass as is | ||
return url; | ||
} | ||
if (selfUrl.host === expandedUrl.host) { | ||
// This is a request to a "local" resource. | ||
// No need to rewrite | ||
return url; | ||
} | ||
|
||
// Check if the host matches the Allow List. | ||
// if not, no rewrite takes place. | ||
// will fail in the Editor if the | ||
// remote host does not accept anonymous | ||
if (!isInsideAllowList(expandedUrl.host)) { | ||
return url; | ||
} | ||
} catch (_) { | ||
// in case of error just silently pass as-is | ||
return url; | ||
} | ||
|
||
// Everything else is a "remote" resource and requires a rewrite. | ||
return `/cors-proxy/${encodeURIComponent(url)}`; | ||
} | ||
|
||
/** | ||
* Check the provided host is allowed to be routed | ||
* to the Proxy. | ||
*/ | ||
function isInsideAllowList(host: string) { | ||
const allowList = getAllowList(); | ||
if (allowList.length === 0) { | ||
// Allow List defaults to allow all if empty | ||
return true; | ||
} | ||
for (const entry of allowList) { | ||
if (entry.toLowerCase().trim() === host) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
/** | ||
* Check if the proxy is enabled via the plugin by checking | ||
* for `import.meta.env.VITE_MC_PROXY_ENABLED` | ||
* | ||
* @remarks The value can either be 'true' of 'false' | ||
* (as strings) if present, or be undefined if not run | ||
* from a vite context or run without the MC Plugin. | ||
*/ | ||
export function isProxyEnabled() { | ||
if (import.meta.env) { | ||
return import.meta.env.VITE_MC_PROXY_ENABLED === 'true'; | ||
} | ||
return false; | ||
} | ||
|
||
/** | ||
* Cached value so getAllowList does not | ||
* try to parse the Env var on every call, | ||
* spamming the console in the process | ||
*/ | ||
let getAllowListCache: string[] | undefined = undefined; | ||
/** | ||
* Return the list of allowed hosts | ||
* from the Plugin Config | ||
*/ | ||
function getAllowList() { | ||
// Condition should get optimized away for Prod | ||
if (import.meta.env.VITEST !== 'true') { | ||
if (getAllowListCache) { | ||
return [...getAllowListCache]; | ||
} | ||
} | ||
|
||
// Inline function gets immediately invoked | ||
// and the result stored in getAllowListCache. | ||
// The cached value is used on subsequent requests. | ||
const result = (function () { | ||
if (!isProxyEnabled() || !import.meta.env) { | ||
return []; | ||
} | ||
|
||
// This value is encoded as a JSON String. | ||
const valueJson = import.meta.env.VITE_MC_PROXY_ALLOW_LIST ?? '[]'; | ||
const parsedJson = JSON.parse(valueJson); | ||
// Do an additional check that only strings are present, | ||
// create a warning and ignore the value | ||
if (!Array.isArray(parsedJson)) { | ||
useLogger().error( | ||
'Parsed Allow List expected to be an Array, but is ' + | ||
typeof parsedJson, | ||
); | ||
} | ||
const validatedEntries = []; | ||
for (const entry of parsedJson) { | ||
if (typeof entry !== 'string') { | ||
useLogger().warn( | ||
'Unexpected Value in Allow List: ' + | ||
entry + | ||
'. Expected a String. Skipping.', | ||
); | ||
continue; | ||
} | ||
validatedEntries.push(entry); | ||
} | ||
return validatedEntries; | ||
})(); | ||
getAllowListCache = result; | ||
return [...getAllowListCache]; | ||
} |
Oops, something went wrong.