-
Notifications
You must be signed in to change notification settings - Fork 39
Improve proxy security (#100) #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 27 commits
84a2537
b82151e
b0ddaee
1a1a703
3c60072
2819f15
0aec2c0
bb960b8
7873d0d
16704da
dcf9c3c
df1f14b
9bd40f4
14da548
d9e56be
49e3721
3c3cc73
890f50b
54f263b
635dc6f
a1a72e2
4ed2e6f
12833a9
30bcc4f
2826a75
c378114
6201c12
12ac8ba
f368010
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -3,3 +3,23 @@ | |||||
HTTP-server to proxy all RSS fetching request from web client. | ||||||
|
||||||
User could use it to bypass censorship or to try web client before they install upcoming extension (to bypass CORS limit of web app). | ||||||
|
||||||
## Scripts | ||||||
|
||||||
### Start Proxy | ||||||
|
||||||
```sh | ||||||
pnpm start | ||||||
# // Proxy server running on port 5284 | ||||||
``` | ||||||
|
||||||
## Abuse Protection | ||||||
|
||||||
- Proxy allows only GET requests | ||||||
- Proxy do not allow requests to reserved ip addresses like `127.0.0.1` | ||||||
- Proxy allows only http or https | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
- Proxy removes cookie headers | ||||||
|
||||||
## Test Strategy | ||||||
|
||||||
Proxy is tested using e2e testing. To write tests `initTestHttpServer` should be used | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Docs are for juniors. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,43 +1,19 @@ | ||
import { createServer } from 'node:http' | ||
import { styleText } from 'node:util' | ||
|
||
const server = createServer(async (req, res) => { | ||
let url = decodeURIComponent(req.url!.slice(1)) | ||
let sent = false | ||
import { createProxyServer } from './proxy.js' | ||
|
||
try { | ||
let proxy = await fetch(url, { | ||
headers: { | ||
...(req.headers as HeadersInit), | ||
host: new URL(url).host | ||
}, | ||
method: req.method | ||
}) | ||
const PORT = 5284 | ||
|
||
res.writeHead(proxy.status, { | ||
'Access-Control-Allow-Headers': '*', | ||
'Access-Control-Allow-Methods': 'OPTIONS, POST, GET, PUT, DELETE', | ||
'Access-Control-Allow-Origin': '*', | ||
'Content-Type': proxy.headers.get('content-type') ?? 'text/plain' | ||
}) | ||
sent = true | ||
res.write(await proxy.text()) | ||
res.end() | ||
} catch (e) { | ||
if (e instanceof Error) { | ||
process.stderr.write(styleText('red', e.stack ?? e.message) + '\n') | ||
if (!sent) { | ||
res.writeHead(500, { 'Content-Type': 'text/plain' }) | ||
res.end('Internal Server Error') | ||
} | ||
} else if (typeof e === 'string') { | ||
process.stderr.write(styleText('red', e) + '\n') | ||
} | ||
} | ||
const IS_PRODUCTION = process.env.NODE_ENV === 'production' | ||
const PRODUCTION_DOMAIN_SUFFIX = '.slowreader.app' | ||
|
||
const proxy = createProxyServer({ | ||
isProduction: IS_PRODUCTION, | ||
ai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
productionDomainSuffix: PRODUCTION_DOMAIN_SUFFIX | ||
}) | ||
|
||
server.listen(5284, () => { | ||
process.stderr.write( | ||
styleText('green', 'Proxy server running on port 5284\n') | ||
proxy.listen(PORT, () => { | ||
process.stdout.write( | ||
styleText('green', `Proxy server running on port ${PORT}`) | ||
) | ||
}) |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,126 @@ | ||||||||
// @ts-ignore | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let’s define simple types in |
||||||||
import isMartianIP from 'martian-cidr' | ||||||||
import type http from 'node:http' | ||||||||
import { createServer } from 'node:http' | ||||||||
import { isIP } from 'node:net' | ||||||||
import { styleText } from 'node:util' | ||||||||
|
||||||||
class BadRequestError extends Error { | ||||||||
constructor(message: string) { | ||||||||
super(message) | ||||||||
this.name = 'BadRequestError' | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
export const createProxyServer = ( | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let’s use |
||||||||
config: { | ||||||||
fetchTimeout?: number | ||||||||
hostnameWhitelist?: string[] | ||||||||
isProduction?: boolean | ||||||||
productionDomainSuffix?: string | ||||||||
silentMode?: boolean | ||||||||
} = {} | ||||||||
): http.Server => { | ||||||||
// Main toggle for production features | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The arguments are very simple to explain them in comments (in contrast with different hacks) |
||||||||
let isProduction = config.isProduction || false | ||||||||
// Silence the output. Used for testing | ||||||||
let silentMode = config.silentMode || false | ||||||||
// Allow request to certain ips like 'localhost'. Used for testing purposes | ||||||||
let hostnameWhitelist = config.hostnameWhitelist || [] | ||||||||
// if isProduction, then only request from origins that match this param are allowed | ||||||||
let productionDomainSuffix = config.productionDomainSuffix || '' | ||||||||
let fetchTimeout = config.fetchTimeout || 2500 | ||||||||
|
||||||||
return createServer(async (req, res) => { | ||||||||
let url = decodeURIComponent(req.url!.slice(1)) | ||||||||
let sent = false | ||||||||
|
||||||||
try { | ||||||||
// Only http or https protocols are allowed | ||||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) { | ||||||||
throw new BadRequestError('Only HTTP or HTTPS are supported') | ||||||||
} | ||||||||
|
||||||||
// Other requests are typically used to modify the data, and we do not typically need them to load RSS | ||||||||
if (req.method !== 'GET') { | ||||||||
throw new BadRequestError('Only GET requests are allowed') | ||||||||
} | ||||||||
|
||||||||
// In production mode we only allow request from production domain | ||||||||
if (isProduction) { | ||||||||
let origin = req.headers.origin | ||||||||
if (!origin?.endsWith(productionDomainSuffix)) { | ||||||||
throw new BadRequestError('Unauthorized Origin') | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
let requestUrl = new URL(url) | ||||||||
if (!hostnameWhitelist.includes(requestUrl.hostname)) { | ||||||||
// Do not allow requests to addresses that are reserved: | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
We should explain “why we did it” not “what we did” |
||||||||
// 127.* | ||||||||
// 192.168.*,* | ||||||||
// https://en.wikipedia.org/wiki/Reserved_IP_addresses | ||||||||
if ( | ||||||||
(isIP(requestUrl.hostname) === 4 || | ||||||||
isIP(requestUrl.hostname) === 6) && | ||||||||
isMartianIP(requestUrl.hostname) | ||||||||
) { | ||||||||
throw new BadRequestError('Requests to reserved ips are not allowed') | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
// Remove all cookie headers and host header from request | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
delete req.headers.cookie | ||||||||
delete req.headers['set-cookie'] | ||||||||
delete req.headers.host | ||||||||
|
||||||||
let targetResponse = await fetch(url, { | ||||||||
headers: { | ||||||||
...(req.headers as HeadersInit), | ||||||||
host: new URL(url).host | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we write client’s IP to avoid using proxy to hide IP? There is Later we will add privacy mechanism for paid users. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. Let me take some time to research usecases and write tests :D |
||||||||
}, | ||||||||
method: req.method, | ||||||||
signal: AbortSignal.timeout(fetchTimeout) | ||||||||
}) | ||||||||
|
||||||||
res.writeHead(targetResponse.status, { | ||||||||
'Access-Control-Allow-Headers': '*', | ||||||||
'Access-Control-Allow-Methods': 'OPTIONS, POST, GET, PUT, DELETE', | ||||||||
'Access-Control-Allow-Origin': req.headers.Origin || '*', | ||||||||
'Content-Type': | ||||||||
targetResponse.headers.get('content-type') ?? 'text/plain' | ||||||||
}) | ||||||||
sent = true | ||||||||
res.write(await targetResponse.text()) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you want to also improve a performance a little? Why do we need to first create a full copy of the HTTP document in the proxy memory and only then send it to the user? Instead, on receiving first bites we can send them to the client. It improves performance and memory consumption. https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams |
||||||||
res.end() | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is another nice abuse protection system. We can limit the size of response https://github.com/gridaco/cors.sh/blob/main/services/proxy.cors.sh/src/limit/payload-limit.ts |
||||||||
} catch (e) { | ||||||||
// Known errors: | ||||||||
if (e instanceof Error && e.name === 'TimeoutError') { | ||||||||
res.writeHead(400, { 'Content-Type': 'text/plain' }) | ||||||||
res.end('Bad Request: Request was aborted due to timeout') | ||||||||
return | ||||||||
} | ||||||||
|
||||||||
if (e instanceof BadRequestError) { | ||||||||
res.writeHead(400, { 'Content-Type': 'text/plain' }) | ||||||||
res.end(`Bad Request: ${e.message}`) | ||||||||
return | ||||||||
} | ||||||||
|
||||||||
// Unknown or internal errors: | ||||||||
|
||||||||
if (!silentMode) { | ||||||||
if (e instanceof Error) { | ||||||||
process.stderr.write(styleText('red', e.stack ?? e.message) + '\n') | ||||||||
toplenboren marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
} else if (typeof e === 'string') { | ||||||||
process.stderr.write(styleText('red', e) + '\n') | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
if (!sent) { | ||||||||
res.writeHead(500, { 'Content-Type': 'text/plain' }) | ||||||||
res.end('Internal Server Error') | ||||||||
} | ||||||||
} | ||||||||
}) | ||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.