Skip to content

Commit

Permalink
Add camomile (#1)
Browse files Browse the repository at this point in the history
Ref: https://github.com/unifiedjs/rfcs/blob/main/text/0005-camo-image-proxy.md

Co-authored-by: Titus <tituswormer@gmail.com>
  • Loading branch information
Murderlon and wooorm authored Oct 5, 2023
1 parent bc0b75f commit 9183ca5
Show file tree
Hide file tree
Showing 16 changed files with 6,134 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .editorconfig
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
20 changes: 20 additions & 0 deletions .github/workflows/main.yml
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
coverage/
node_modules/
.DS_Store
*.d.ts
*.log
yarn.lock
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.md
5 changes: 5 additions & 0 deletions index.js
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'
133 changes: 133 additions & 0 deletions lib/constants.js
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'
]
148 changes: 148 additions & 0 deletions lib/safe-http-client.js
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}
}
Loading

0 comments on commit 9183ca5

Please sign in to comment.