-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Ref: https://github.com/unifiedjs/rfcs/blob/main/text/0005-camo-image-proxy.md Co-authored-by: Titus <tituswormer@gmail.com>
- Loading branch information
Showing
16 changed files
with
6,134 additions
and
0 deletions.
There are no files selected for viewing
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,9 @@ | ||
root = true | ||
|
||
[*] | ||
indent_style = space | ||
indent_size = 2 | ||
end_of_line = lf | ||
charset = utf-8 | ||
trim_trailing_whitespace = true | ||
insert_final_newline = true |
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,20 @@ | ||
name: main | ||
on: | ||
- pull_request | ||
- push | ||
jobs: | ||
main: | ||
name: ${{matrix.node}} on ${{matrix.os}} | ||
runs-on: ${{matrix.os}} | ||
steps: | ||
- uses: actions/checkout@v3 | ||
- uses: actions/setup-node@v3 | ||
with: | ||
node-version: ${{matrix.node}} | ||
- run: npm install | ||
- run: npm test | ||
strategy: | ||
matrix: | ||
node: | ||
- lts/gallium | ||
- node |
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,6 @@ | ||
coverage/ | ||
node_modules/ | ||
.DS_Store | ||
*.d.ts | ||
*.log | ||
yarn.lock |
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 @@ | ||
*.md |
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,5 @@ | ||
/** | ||
* @typedef {import('./lib/server.js').Options} Options | ||
*/ | ||
|
||
export {Camomile} from './lib/server.js' |
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,133 @@ | ||
/** @type {Readonly<Record<string, string>>} */ | ||
export const securityHeaders = { | ||
/** | ||
* Ensures that the page can’t be displayed in a frame, | ||
* regardless of where the request came from. | ||
* It’s a security feature to prevent “clickjacking” attacks, | ||
* where an attacker might use an iframe to overlay a legitimate page over a | ||
* deceptive page. | ||
*/ | ||
'X-Frame-Options': 'deny', | ||
/** | ||
* The browser should enable the XSS filter and if a XSS attack is detected, | ||
* rather than sanitizing the page, the browser will prevent rendering of the | ||
* page entirely. | ||
*/ | ||
'X-XSS-Protection': '1; mode=block', | ||
/** | ||
* Trust what the server says and not to perform MIME type sniffing. | ||
* This can prevent certain security risks where the browser might interpret | ||
* files as a different MIME type, which can be exploited in attacks. | ||
*/ | ||
'X-Content-Type-Options': 'nosniff', | ||
/** | ||
* By default, do not load content from any source (`default-src 'none'`), | ||
* images can be loaded from `data:` URLs (like Base64 encoded images), | ||
* and styles can be loaded inline (`style-src 'unsafe-inline'`), | ||
* which usually means from within the HTML itself rather than from external | ||
* files. | ||
*/ | ||
'Content-Security-Policy': | ||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'", | ||
/** | ||
* This header is often called HSTS (HTTP Strict Transport Security). | ||
* It’s a security feature that ensures a website can only be accessed over | ||
* HTTPS instead of HTTP. | ||
* The `max-age` parameter specifies how long (in seconds) the browser should | ||
* remember to enforce this policy. | ||
* The `includeSubDomains` directive extends this rule to all subdomains of | ||
* the domain sending the header. | ||
* This ensures that even if a user tries to access the site or its | ||
* subdomains via HTTP, | ||
* their browser will automatically upgrade the request to HTTPS. | ||
*/ | ||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' | ||
} | ||
|
||
/** | ||
* HTTP request headers that are acceptable to pass from the client to the | ||
* remote server. | ||
* Only those present and true are forwarded. | ||
* | ||
* @type {Readonly<Set<string>>} | ||
*/ | ||
export const defaultRequestHeaders = new Set([ | ||
'Accept', | ||
'Accept-Charset', | ||
'Accept-Language', | ||
'Cache-Control', | ||
'If-None-Match', | ||
'If-Modified-Since', | ||
'Range' // used by Safari for byte range requests on video | ||
]) | ||
|
||
/** | ||
* HTTP response headers that are acceptable to pass from the remote server to | ||
* the client. | ||
* Only those present and true are forwarded. | ||
* | ||
* @type {Readonly<Set<string>>} | ||
*/ | ||
export const defaultResponseHeaders = new Set([ | ||
'Accept-Ranges', // used by Safari for byte range requests on video | ||
'Cache-Control', | ||
'Content-Length', | ||
'Content-Encoding', | ||
'Content-Range', | ||
'Content-Type', | ||
'ETag', | ||
'Expires', | ||
'Last-Modified', | ||
'Transfer-Encoding' | ||
]) | ||
|
||
/** | ||
* MIME types that we allow. | ||
* | ||
* @type {ReadonlyArray<string>} | ||
*/ | ||
export const defaultMimeTypes = [ | ||
'image/bmp', | ||
'image/cgm', | ||
'image/g3fax', | ||
'image/gif', | ||
'image/ief', | ||
'image/jp2', | ||
'image/jpeg', | ||
'image/jpg', | ||
'image/pict', | ||
'image/png', | ||
'image/prs.btif', | ||
'image/svg+xml', | ||
'image/tiff', | ||
'image/vnd.adobe.photoshop', | ||
'image/vnd.djvu', | ||
'image/vnd.dwg', | ||
'image/vnd.dxf', | ||
'image/vnd.fastbidsheet', | ||
'image/vnd.fpx', | ||
'image/vnd.fst', | ||
'image/vnd.fujixerox.edmics-mmr', | ||
'image/vnd.fujixerox.edmics-rlc', | ||
'image/vnd.microsoft.icon', | ||
'image/vnd.ms-modi', | ||
'image/vnd.net-fpx', | ||
'image/vnd.wap.wbmp', | ||
'image/vnd.xiff', | ||
'image/webp', | ||
'image/x-cmu-raster', | ||
'image/x-cmx', | ||
'image/x-icon', | ||
'image/x-macpaint', | ||
'image/x-pcx', | ||
'image/x-pict', | ||
'image/x-portable-anymap', | ||
'image/x-portable-bitmap', | ||
'image/x-portable-graymap', | ||
'image/x-portable-pixmap', | ||
'image/x-quicktime', | ||
'image/x-rgb', | ||
'image/x-xbitmap', | ||
'image/x-xpixmap', | ||
'image/x-xwindowdump' | ||
] |
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,148 @@ | ||
import dns from 'node:dns/promises' | ||
import {fetch} from 'undici' | ||
import ipaddr from 'ipaddr.js' | ||
import {defaultMimeTypes} from './constants.js' | ||
|
||
export class HttpError extends Error { | ||
/** | ||
* Create an HTTP error. | ||
* | ||
* @param {number} statusCode | ||
* Status code. | ||
* @param {string} message | ||
* Message. | ||
* @returns | ||
* HTTP error. | ||
*/ | ||
constructor(statusCode, message) { | ||
super(message) | ||
this.statusCode = statusCode | ||
this.name = 'HttpError' | ||
|
||
// Exclude the constructor call from the stack trace. | ||
if (Error.captureStackTrace) { | ||
Error.captureStackTrace(this, HttpError) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Check that the URL is valid, the prototol is allowed, and that the host is | ||
* a safe unicast address. | ||
* | ||
* @param {string} url | ||
* URL to check. | ||
* @returns {Promise<void>} | ||
* URL object. | ||
*/ | ||
export async function checkUrl(url) { | ||
// Throws if the URL is invalid | ||
const validUrl = new URL(url) | ||
const {protocol, hostname} = validUrl | ||
|
||
// Don't allow aother protocols like file:// URLs | ||
if (!['http:', 'https:'].includes(protocol)) { | ||
throw new Error('Bad protocol') | ||
} | ||
|
||
try { | ||
var {address} = await dns.lookup(hostname) | ||
} catch (err) { | ||
throw new Error('Bad url host') | ||
} | ||
|
||
/** | ||
* Server Side Request Forgery (SSRF) Protection. | ||
* | ||
* SSRF is an attack where an attacker can trick a server into making unexpected network connections. | ||
* This can lead to unauthorized access to internal resources, information disclosure, | ||
* denial-of-service attacks, or even remote code execution. | ||
* | ||
* One common SSRF vector is tricking the server into making requests to internal IP addresses | ||
* or to other services within the network that the server shouldn't be accessing. This can | ||
* expose sensitive internal data or systems. | ||
* | ||
* Unicast addresses are typically used for communication between hosts on the public internet. | ||
* By only allowing addresses in the 'unicast' range, we can prevent SSRF attacks targeting | ||
* non-public IP ranges, such as private, multicast, and reserved IPs. | ||
*/ | ||
if (ipaddr.process(address).range() !== 'unicast') { | ||
throw new Error('Bad url host') | ||
} | ||
} | ||
|
||
/** | ||
* Fetch a URL. | ||
* | ||
* @param {URL | string} url | ||
* URL. | ||
* @param {import('undici').RequestInit} options | ||
* Configuration, passed through to `fetch`. | ||
* @param {number} [maxSize] | ||
* The max size in bytes to download. | ||
* @returns {Promise<{buffer?: Buffer, headers: import('undici').Headers}>} | ||
* Buffer of response (except when `HEAD`) and headers. | ||
*/ | ||
export async function safeFetch(url, options, maxSize) { | ||
const redirectCodes = new Set([301, 302, 303, 307, 308]) | ||
const maxRedirects = 3 | ||
let redirectCount = 0 | ||
let response | ||
|
||
while (true) { | ||
response = await fetch(url, options) | ||
|
||
if (!redirectCodes.has(response.status) || redirectCount >= maxRedirects) { | ||
break | ||
} | ||
|
||
const redirectedUrl = response.headers.get('location') | ||
|
||
if (!redirectedUrl) { | ||
throw new HttpError(400, 'Missing `Location` header') | ||
} | ||
|
||
await checkUrl(redirectedUrl) | ||
url = redirectedUrl | ||
redirectCount++ | ||
} | ||
|
||
const contentType = response.headers.get('content-type') | ||
if (!contentType) { | ||
throw new HttpError(400, 'Empty content-type header') | ||
} | ||
|
||
if (!defaultMimeTypes.includes(contentType)) { | ||
throw new HttpError(400, 'Unsupported content-type returned') | ||
} | ||
|
||
if (options.method === 'HEAD') { | ||
return {headers: response.headers} | ||
} | ||
|
||
if (!response.body) { | ||
throw new HttpError(400, 'No response body') | ||
} | ||
|
||
/** @type {Array<Buffer>} */ | ||
const chunks = [] | ||
const reader = response.body.getReader() | ||
let currentByteLength = 0 | ||
|
||
while (true) { | ||
const {done, value} = await reader.read() | ||
if (done) { | ||
break | ||
} | ||
chunks.push(value) | ||
|
||
if (maxSize) { | ||
currentByteLength += value.length | ||
if (currentByteLength > maxSize) { | ||
throw new HttpError(413, 'Content-Length exceeded') | ||
} | ||
} | ||
} | ||
|
||
return {buffer: Buffer.concat(chunks), headers: response.headers} | ||
} |
Oops, something went wrong.