|
// @TODO: Support more than one API key (How/Where to store?) |
|
|
|
function buildRequestOptions(request) { |
|
const requestOptions = {} |
|
for (const [ name, value ] of Object.entries(request)) { |
|
requestOptions[name] = value |
|
} |
|
requestOptions.redirect = 'follow' |
|
|
|
if (request.headers.has('Curse-Method')) { |
|
requestOptions.method = request.headers.get('Curse-Method') |
|
} else if (request.headers.has('Method')) { |
|
requestOptions.method = request.headers.get('Method') |
|
} |
|
|
|
return requestOptions |
|
} |
|
|
|
function buildUrl(request, fetchUrl) { |
|
let url |
|
|
|
/** |
|
* There is a chance that this is a request done by a page loaded through this proxy. |
|
* If that is the case, the hostname should match us, and there should be a valid URL in the referrers' path. |
|
* Note: The header name "referer" is actually a misspelling of the word "referrer". |
|
* For more details see: https://en.wikipedia.org/wiki/HTTP_referer |
|
*/ |
|
const referrer = request.headers.get('Referer') |
|
|
|
let validReferrer = getValidReferrer(referrer, fetchUrl) |
|
|
|
try { |
|
url = new URL(fetchUrl) |
|
} catch (e) { |
|
if (validReferrer) { |
|
// Try again with referrer URL |
|
url = validReferrer |
|
} else { |
|
throw new Error('Invalid referrer') |
|
} |
|
} |
|
|
|
return url |
|
} |
|
|
|
function createCachedResponse(request, url, headers, cacheTtl) { |
|
headers.cf = { cacheTtl } |
|
|
|
return createFetchResponse(request, url, headers) |
|
} |
|
|
|
function createErrorResponse(title, message, headers, status, statusText) { |
|
headers['Content-Type'] = 'text/html; charset=utf-8' |
|
|
|
if ( ! message) { |
|
message = `<p>${statusText}</p>` |
|
} |
|
|
|
// If the HTML is less than 512 bytes Chrome doesn't show it |
|
const padding = 1024 |
|
if (message.length < padding) { |
|
const paddingString = '.'.repeat(padding - message.length) |
|
|
|
message += `<!-- ${paddingString} -->` |
|
} |
|
|
|
let body = `<!doctype html><html lang="en"><meta charset="UTF-8"> |
|
<link rel="shortcut icon" href="data:image/svg+xml,<svg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'><text x='0' y='15'>🤬</text></svg>" /> |
|
<style>title{display:inline;}h1:after{content:" (${status})"; color: #CBB}h1:before{content:"Error: "; color:crimson}</style><title>${title}</title><h1>${title}</h1>${message}</html> |
|
` |
|
|
|
return new Response(body, { headers, status, statusText }) |
|
} |
|
|
|
async function createFetchResponse(request, fetchUrl, headers) { |
|
let response |
|
const url = buildUrl(request, fetchUrl) |
|
|
|
try { |
|
response = await makeRequest(request, url) |
|
} catch (error) { |
|
return createErrorResponse( |
|
`The provided URL <a href="${fetchUrl}">${fetchUrl}<a/> could not be reached.`, |
|
null, headers, 502, 'Naughty Gateway', |
|
) |
|
} |
|
|
|
const responseHeaders = createResponseHeaders(response, headers) |
|
|
|
return new Response(response.body, { |
|
headers: responseHeaders, |
|
status: response.status, |
|
statusText: response.statusText, |
|
}) |
|
} |
|
|
|
function createHomepageResponse(headers, request) { |
|
const message = `<!doctype html> |
|
<html lang="en"> |
|
<meta charset="UTF-8"><title>Potherca's CORS Proxy</title> |
|
<link rel="shortcut icon" href="data:image/svg+xml,<svg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'><text x='0' y='15'>🤬</text></svg>" /> |
|
<dl> |
|
<dt><h1><strong>cors</strong> <small>(noun)</small></h1></dt> |
|
<dd><em>A Middle English form of curse.</em></dd> |
|
</dl> |
|
<p> |
|
Call this with a URL appended to receive that URL |
|
<em>with</em> <code>Access-Control-Allow-Origin: *</code> headers. |
|
</p> |
|
<p> |
|
All request Headers will be passed along by the proxy. |
|
</p> |
|
<p> |
|
Don't forget to include your API key in the <code>Authorization</code> header! |
|
</p> |
|
<pre><code> |
|
<script> |
|
fetch('${request.url}https://example.com', { |
|
headers: { |
|
"Authorization": "token <api-key>" |
|
} |
|
}) |
|
</script> |
|
</code></pre> |
|
<p>If your request requires a <code>Authorization</code> header, the API key for Curse can be passed |
|
in a <code>Curse-Authorization</code> header instead: |
|
<pre><code> |
|
<script> |
|
fetch('${request.url}https://example.com', { |
|
headers: { |
|
"Curse-Authorization": "token <api-key>" |
|
} |
|
}) |
|
</script> |
|
</code></pre> |
|
|
|
` |
|
headers['Content-Type'] = 'text/html; charset=utf-8' |
|
return new Response(message, { headers, status: 200, statusText: 'Awesome!' }) |
|
} |
|
|
|
function createPreflightResponse(request, headers) { |
|
headers['Access-Control-Allow-Headers'] = request.headers.get('Access-Control-Request-Headers') |
|
return new Response(null, { headers }) |
|
} |
|
|
|
function createResponseHeaders(response, headers) { |
|
const responseHeaders = new Headers(response.headers) |
|
|
|
Object.keys(headers).map(function (name) { |
|
responseHeaders.set(name, headers[name]) |
|
}) |
|
|
|
responseHeaders.delete('content-security-policy-report-only') |
|
return responseHeaders |
|
} |
|
|
|
function getApiKey(request) { |
|
let apikey |
|
|
|
if (request.headers.has('Curse-Authorization')) { |
|
apikey = request.headers.get('Curse-Authorization') |
|
} else if (request.headers.has('Authorization')) { |
|
apikey = request.headers.get('Authorization') |
|
} else { |
|
apikey = '' |
|
} |
|
|
|
if (apikey.includes(' ')) { |
|
apikey = apikey.split(' ', 2)[1] |
|
} |
|
|
|
return apikey |
|
} |
|
|
|
function getValidReferrer(referrer, path) { |
|
let validReferrer |
|
|
|
if (referrer) { |
|
const referrerUrl = new URL(referrer) |
|
const referrerHostname = referrerUrl.hostname |
|
const referrerPathname = referrerUrl.pathname |
|
let referrerPath |
|
|
|
// @FIXME: How do we get the current Worker URL to check against? |
|
// Doing this hard-coded for now :-/ |
|
if (referrerHostname === 'curse.potherca.workers.dev') { |
|
referrerPath = referrerPathname.substring(1) |
|
.replace('http:/', 'http://') |
|
.replace('https:/', 'https://') |
|
} |
|
|
|
try { |
|
validReferrer = new URL(`${referrerPath}/${path}`) |
|
} catch (e) { |
|
} |
|
} |
|
return validReferrer |
|
} |
|
|
|
async function makeRequest(request, url) { |
|
const requestOptions = buildRequestOptions(request) |
|
return await fetch(url, requestOptions) |
|
} |
|
|
|
function validateRequest(env, request, path, headers) { |
|
let errorResponse |
|
|
|
const allowedOrigins = env.ALLOWED_ORIGINS.split(',').map(o => o.trim()) |
|
let apikey = getApiKey(request) |
|
|
|
let origin = request.headers.get('Origin') |
|
|
|
let originHost |
|
try { |
|
originHost = new URL(origin)?.hostname |
|
} catch (error) { |
|
originHost = '' |
|
} |
|
|
|
function isValidOrigin(allowedOrigins, originHost) { |
|
let isValid = false |
|
|
|
if (allowedOrigins === null || allowedOrigins === '*') { |
|
isValid = true |
|
} else if (allowedOrigins.includes(originHost) || originHost === 'dash.cloudflare.com') { |
|
isValid = true |
|
} else { |
|
for (origin of allowedOrigins) { |
|
if (originHost.endsWith(origin)) { |
|
isValid = true |
|
break |
|
} |
|
} |
|
} |
|
|
|
return isValid |
|
} |
|
|
|
if (apikey !== env.API_KEY) { |
|
errorResponse = createErrorResponse( |
|
`The required <code>Authorization</code> header is ${apikey ? 'incorrect' : 'missing'}`, |
|
null, headers, 407, 'Please provide a valid API key.', |
|
) |
|
} else if ( ! origin && allowedOrigins !== null && allowedOrigins !== '*') { |
|
errorResponse = createErrorResponse( |
|
'Origin header missing', |
|
null, headers, 403, 'Forbidden, requires Origin', |
|
) |
|
} else if ( ! isValidOrigin(allowedOrigins, originHost)) { |
|
errorResponse = createErrorResponse( |
|
`Provided origin "${originHost}" is not allowed`, |
|
null, headers, 403, 'Forbidden, invalid Origin', |
|
) |
|
} else { |
|
try { |
|
buildUrl(request, path) |
|
} catch (error) { |
|
errorResponse = createErrorResponse( |
|
`The provided path "<a href="${path}">${path}<a/>" is not a valid URL`, |
|
null, headers, 400, 'Invalid URL Provided', |
|
) |
|
} |
|
} |
|
|
|
return errorResponse |
|
} |
|
|
|
export default { |
|
async fetch(request, env) { |
|
const { pathname, searchParams } = new URL(request.url) |
|
|
|
let path = pathname.substring(1) |
|
.replace('http:/', 'http://') |
|
.replace('https:/', 'https://') |
|
|
|
if (searchParams.size > 0) { |
|
path += '?' + searchParams |
|
} |
|
|
|
const headers = { |
|
'Access-Control-Allow-Headers': '*', |
|
'Access-Control-Allow-Methods': '*', |
|
'Access-Control-Allow-Origin': '*', |
|
'Access-Control-Expose-Headers': '*', |
|
'Content-Security-Policy': [ |
|
'child-src *', |
|
'connect-src *', |
|
'default-src *', |
|
'font-src *', |
|
'frame-src *', |
|
"'img-src * 'self' data: blob:'", |
|
'manifest-src *', |
|
'media-src *', |
|
'object-src *', |
|
"script-src * 'unsafe-eval'", |
|
'script-src-attr *', |
|
"script-src-elem * 'self' 'unsafe-inline'", |
|
'style-src *', |
|
"style-src-attr 'unsafe-inline'", |
|
"style-src-elem * 'self' 'unsafe-inline'", |
|
'worker-src *', |
|
].join(';'), |
|
'Permissions-Policy': 'autoplay=*, camera=*, fullscreen=*, geolocation=*, microphone=*, web-share=*', |
|
'Referrer-Policy': 'unsafe-url', |
|
'X-Clacks-Overhead': 'GNU Terry Pratchett', |
|
'X-Frame-Options': 'SAMEORIGIN', |
|
'X-Requested-Path': path, |
|
} |
|
// Check if all required environment variables are set. |
|
const requiredEnv = [ |
|
'API_KEY', |
|
'ALLOWED_ORIGINS', |
|
] |
|
|
|
const missingEnv = requiredEnv.filter(key => ! env[key]) |
|
|
|
let response |
|
|
|
if (missingEnv.length > 0) { |
|
response = createErrorResponse( |
|
'Missing environment variables', |
|
`<ul><li>${missingEnv.join('</li><li>')}</li></ul>`, headers, 500, 'Please set missing ENV vars.', |
|
) |
|
} else if ( |
|
request.headers.get('Origin') !== null |
|
&& request.headers.get('Access-Control-Request-Headers') !== null |
|
&& request.headers.get('Access-Control-Request-Method') !== null |
|
) { |
|
// Send CORS preflight response. |
|
response = createPreflightResponse(request, headers) |
|
} else if (path === '') { |
|
response = createHomepageResponse(headers, request) |
|
} else if (path === 'favicon.ico') { |
|
const url = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/refs/heads/main/png/512/emoji_u1f92c.png' |
|
const oneYearInSeconds = 31536000 // 60 * 60 * 24 * 365 |
|
|
|
response = createCachedResponse(request, url, headers, oneYearInSeconds) |
|
} else { |
|
const errorResponse = validateRequest(env, request, path, headers) |
|
|
|
if (errorResponse) { |
|
response = errorResponse |
|
} else { |
|
response = await createFetchResponse(request, path, headers) |
|
} |
|
} |
|
|
|
return response |
|
}, |
|
} |