-
-
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.
- Loading branch information
0 parents
commit b693140
Showing
18 changed files
with
6,066 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,9 @@ | ||
{ | ||
"tabWidth": 2, | ||
"useTabs": false, | ||
"singleQuote": true, | ||
"bracketSpacing": false, | ||
"semi": false, | ||
"trailingComma": "none", | ||
"proseWrap": "always" | ||
} |
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,7 @@ | ||
import remarkPresetWooorm from 'remark-preset-wooorm' | ||
|
||
const remarkConfig = { | ||
plugins: [remarkPresetWooorm] | ||
} | ||
|
||
export default remarkConfig |
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,2 @@ | ||
export {Server} from './lib/server.js' | ||
export * from './lib/constants.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,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' | ||
] |
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,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} | ||
} | ||
} |
Oops, something went wrong.