Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Murderlon committed Sep 15, 2023
0 parents commit b693140
Show file tree
Hide file tree
Showing 18 changed files with 6,066 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
9 changes: 9 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"bracketSpacing": false,
"semi": false,
"trailingComma": "none",
"proseWrap": "always"
}
7 changes: 7 additions & 0 deletions .remarkrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import remarkPresetWooorm from 'remark-preset-wooorm'

const remarkConfig = {
plugins: [remarkPresetWooorm]
}

export default remarkConfig
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {Server} from './lib/server.js'
export * from './lib/constants.js'
112 changes: 112 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/** @constant */
export const DEFAULT_HEADERS = {
// 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).
// 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.
* @constant
*/
export const REQUEST_HEADERS = new Map([
['Accept', true],
['Accept-Charset', true],
['Accept-Encoding', false], // images (aside from xml/svg), don't typically benefit from compression
['Accept-Language', true],
['Cache-Control', true],
['If-None-Match', true],
['If-Modified-Since', true],
['X-Forwarded-For', false], // x-forwarded-for header is not blindly passed without additional custom processing
['Range', true] // required to support safari byte range requests for video
])

/**
* http response headers that are acceptable to pass from
* the remote server to the client. Only those present and true, are forwarded.
* @constant
*/
export const RESPONSE_HEADERS = new Map([
['Accept-Ranges', true], // required to support Safari byte range requests for video
['Content-Length', true],
['Content-Range', true],

['Cache-Control', true],
['Content-Encoding', true],
['Content-Type', true],
['ETag', true],
['Expires', true],
['Last-Modified', true],
['Server', false], // override in response with either nothing, or ServerNameVer
['Transfer-Encoding', true]
])

/** @constant */
export const IMAGE_MIME_TYPES = [
'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'
]
134 changes: 134 additions & 0 deletions lib/safe-http-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import dns from 'node:dns/promises'

import {fetch} from 'undici'
import ipaddr from 'ipaddr.js'

import {IMAGE_MIME_TYPES} from './constants.js'

export class HttpError extends Error {
/**
* @param {number} statusCode
* @param {string} message
*/
constructor(statusCode, message) {
super(message)
this.statusCode = statusCode
this.name = 'HttpError'

// Captures the stack trace, excluding the constructor call from the stack trace.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, HttpError)
}
}
}

export class SafeHttpClient {
/** @param {number} [maxSize] */
constructor(maxSize) {
this.maxSize = maxSize
}

/**
* Check if the URL is a valid URL or IP, that the prototol is valid,
* and the host is a safe unicast address.
* @param {string} url
*/
static async 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')
}

return validUrl
}

/**
* @param {string|URL} url
* @param {import('undici').RequestInit} options
*/
async safeFetch(url, options) {
let response = await fetch(url, options)

// If there's a redirect, check the redirected URL for SSRF and then follow it if it's valid.
if ([301, 302, 303, 307, 308].includes(response.status)) {
const redirectedUrl = response.headers.get('location')
// @ts-ignore can pass undefined redirectedUrl
await SafeHttpClient.checkUrl(redirectedUrl)

// @ts-ignore redirectedUrl guarded by checkUrl
response = await fetch(redirectedUrl, {
...options,
// Do not allow another redirect
redirect: 'error'
})
}

const contentType = response.headers.get('content-type')
if (!contentType || contentType.length === 0) {
throw new HttpError(400, 'Empty content-type header')
}

if (!IMAGE_MIME_TYPES.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 {Uint8Array[]} */
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 (this.maxSize) {
currentByteLength += value.length
if (currentByteLength > this.maxSize) {
throw new HttpError(404, 'Content-Length exceeded')
}
}
}

return {buffer: Buffer.concat(chunks), headers: response.headers}
}
}
Loading

0 comments on commit b693140

Please sign in to comment.