From a553a69d8df734c22cf599a7b2530bcca5b14db0 Mon Sep 17 00:00:00 2001 From: Abhijith V Date: Sat, 14 Feb 2026 20:53:22 +0530 Subject: [PATCH] added more security --- README.md | 22 ++++ SECURITY.md | 125 ++++++++++++++++++++ package-lock.json | 166 ++++++++++++++------------- packages/client/README.md | 32 ++++++ packages/client/src/index.ts | 8 +- packages/client/src/lib/inspector.ts | 14 ++- packages/client/src/lib/socket.ts | 46 ++++++-- packages/server/package.json | 4 +- packages/server/src/index.ts | 53 +++++++-- packages/server/src/lib/socket.ts | 54 ++++++++- 10 files changed, 415 insertions(+), 109 deletions(-) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 66d61bf..f9894cc 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ curl -H "X-Proxy-Token: mysecrettoken" https://your-tunnel.proxyhub.cloud/ | `-t, --token ` | Token for tunnel protection | | `-i, --inspect` | Enable request inspector UI | | `--inspect-port ` | Port for inspector UI (default: port + 1000) | +| `-k, --auth-key ` | Authentication key for the ProxyHub server | | `-d, --debug` | Enable debug mode | | `-V, --version` | Output version number | | `-h, --help` | Display help | @@ -182,6 +183,11 @@ PROXYHUB_SOCKET_URL=https://your-server.com proxyhub -p 3000 | `PROTOCOL` | `https` | Protocol for generated URLs | | `SOCKET_PATH` | `/socket.io` | Socket.IO path | | `CONNECTION_TIMEOUT_MINUTES` | `30` | Session timeout (0 = unlimited) | +| `SOCKET_AUTH_KEY` | - | Shared key for socket authentication | +| `ALLOWED_ORIGINS` | - | Comma-separated list of allowed CORS origins | +| `RATE_LIMIT_WINDOW_MS` | `600000` | HTTP rate limit window (ms) | +| `RATE_LIMIT_MAX_REQUESTS` | `5000` | Max HTTP requests per window | +| `SOCKET_MAX_CONNECTIONS_PER_MINUTE` | `30` | Max socket connections per IP per minute | ### Client @@ -190,6 +196,8 @@ PROXYHUB_SOCKET_URL=https://your-server.com proxyhub -p 3000 | `PROXYHUB_SOCKET_URL` | `https://connect.proxyhub.cloud` | ProxyHub server URL | | `PROXYHUB_SOCKET_PATH` | `/socket.io` | Socket.IO path | | `PROXYHUB_TOKEN` | - | Token for tunnel protection | +| `PROXYHUB_AUTH_KEY` | - | Authentication key for the server | +| `PROXYHUB_ALLOW_INSECURE` | - | Allow self-signed TLS certificates | ## How It Works @@ -206,6 +214,20 @@ Internet Request Your Local Server (proxyhub.cloud) (your machine) ``` +## Security + +ProxyHub includes several security features for production use: + +- **Socket authentication** — set `SOCKET_AUTH_KEY` on the server and `--auth-key` on the client to restrict connections +- **TLS verification** — enabled by default; opt out with `PROXYHUB_ALLOW_INSECURE` for self-signed certs +- **Rate limiting** — HTTP (5000 requests/10 min) and WebSocket (30 connections/min per IP) rate limits protect against abuse +- **CORS restrictions** — configure `ALLOWED_ORIGINS` to restrict cross-origin access +- **Security headers** — helmet middleware sets secure HTTP response headers +- **Header filtering** — hop-by-hop headers are stripped from proxied requests +- **Unpredictable tunnel IDs** — cryptographically random tunnel URLs + +See [SECURITY.md](SECURITY.md) for full details, configuration options, and the list of fixed security issues. + ## Development ```bash diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..668a428 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,125 @@ +# Security + +ProxyHub takes security seriously. This document describes the security features available, known issues that have been addressed, and how to configure security options. + +## Security Features + +### Socket.IO Authentication + +Restrict which clients can connect to your ProxyHub server by setting a shared authentication key. + +**Server:** + +```bash +SOCKET_AUTH_KEY=your-secret-key node dist/index.js +``` + +**Client:** + +```bash +proxyhub -p 3000 --auth-key your-secret-key + +# Or via environment variable +PROXYHUB_AUTH_KEY=your-secret-key proxyhub -p 3000 +``` + +When `SOCKET_AUTH_KEY` is set on the server, only clients providing the matching key can establish a tunnel. When unset, any client can connect (backward compatible). + +Authentication uses `crypto.timingSafeEqual` to prevent timing attacks. + +### TLS Certificate Verification + +The client enforces TLS certificate verification by default. To allow self-signed certificates (e.g., in development), set: + +```bash +PROXYHUB_ALLOW_INSECURE=1 proxyhub -p 3000 +``` + +### CORS Restrictions + +**Server:** Restrict allowed origins for HTTP and WebSocket connections: + +```bash +ALLOWED_ORIGINS=https://example.com,https://app.example.com node dist/index.js +``` + +When unset, all origins are allowed (backward compatible). + +**Inspector:** The inspector UI only accepts CORS requests from `localhost` and `127.0.0.1`. + +### Token Protection + +Secure individual tunnels with per-tunnel tokens: + +```bash +proxyhub -p 3000 --token mysecrettoken +``` + +Requests must include the `X-Proxy-Token` header. See the main [README](README.md#token-protection) for details. + +### Rate Limiting + +**HTTP requests** are rate-limited by default (5000 requests per 10-minute window per IP). The `/status` and `/health` endpoints are excluded. + +**Socket.IO connections** are limited to 30 connections per minute per IP. + +Configure via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_WINDOW_MS` | `600000` (10 min) | HTTP rate limit window in milliseconds | +| `RATE_LIMIT_MAX_REQUESTS` | `100` | Max HTTP requests per window per IP | +| `SOCKET_MAX_CONNECTIONS_PER_MINUTE` | `10` | Max socket connections per minute per IP | + +### Security Headers + +The server uses [helmet](https://helmetjs.github.io/) to set secure HTTP headers including Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, and others. + +### Request Body Limits + +The server limits request bodies to 10 MB to prevent denial-of-service via oversized payloads. + +### Header Filtering + +Hop-by-hop headers (`Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`, `Proxy-Authenticate`, `Proxy-Authorization`) are stripped from proxied requests to prevent header injection and protocol confusion attacks. + +### Unpredictable Tunnel IDs + +Tunnel IDs are generated using `crypto.randomBytes(16)` (32-character hex strings) instead of deterministic hashes. IDs are persisted in `~/.proxyhub/tunnel-ids.json` for stability across restarts while remaining unpredictable to attackers. + +## Fixed Security Issues + +| ID | Severity | Issue | Fix | +|----|----------|-------|-----| +| H8 | High | Vulnerable dependencies | Updated dependencies via `npm audit fix` | +| H4 | High | Hop-by-hop headers forwarded to tunnels | Headers are now stripped before forwarding | +| H3 | High | No request body size limit | Added 10 MB limit on JSON and URL-encoded bodies | +| H1 | High | No rate limiting | Added HTTP and Socket.IO rate limiting | +| C1 | Critical | No socket authentication | Added opt-in shared-key authentication with timing-safe comparison | +| C2 | Critical | TLS certificate verification disabled | Enabled by default; opt-out via `PROXYHUB_ALLOW_INSECURE` | +| C3 | Critical | Predictable tunnel IDs | Replaced MD5-based IDs with cryptographically random IDs | +| C4 | Critical | Unrestricted CORS | Server respects `ALLOWED_ORIGINS`; inspector restricted to localhost | +| M3 | Medium | Missing security headers | Added helmet middleware | + +## Environment Variables Reference + +### Server + +| Variable | Default | Description | +|----------|---------|-------------| +| `SOCKET_AUTH_KEY` | unset (no auth) | Shared key for socket authentication | +| `ALLOWED_ORIGINS` | unset (allow all) | Comma-separated list of allowed CORS origins | +| `RATE_LIMIT_WINDOW_MS` | `600000` | HTTP rate limit window (ms) | +| `RATE_LIMIT_MAX_REQUESTS` | `5000` | Max HTTP requests per window | +| `SOCKET_MAX_CONNECTIONS_PER_MINUTE` | `30` | Max socket connections per IP per minute | + +### Client + +| Variable | Default | Description | +|----------|---------|-------------| +| `PROXYHUB_AUTH_KEY` | unset (random) | Authentication key sent to the server | +| `PROXYHUB_ALLOW_INSECURE` | unset (TLS on) | Set to allow self-signed TLS certificates | + +## Reporting Vulnerabilities + +If you discover a security vulnerability, please report it responsibly by opening a private issue on [GitHub](https://github.com/cube-root/proxyhub/issues) or contacting the maintainers directly. Do not disclose vulnerabilities publicly until a fix is available. diff --git a/package-lock.json b/package-lock.json index 8334740..d81d2ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -638,23 +638,6 @@ } } }, - "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@inquirer/figures": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", @@ -1675,23 +1658,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boxen": { @@ -2201,9 +2188,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2328,9 +2315,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2795,6 +2782,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/fast-content-type-parse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", @@ -3206,29 +3220,33 @@ "node": ">= 0.4" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, "engines": { - "node": ">= 0.8" + "node": ">=18.0.0" } }, - "node_modules/http-errors/node_modules/statuses": { + "node_modules/http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -3270,15 +3288,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -4645,9 +4667,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4690,18 +4712,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/rc": { @@ -6318,7 +6340,7 @@ }, "packages/client": { "name": "proxyhub", - "version": "0.0.2", + "version": "0.1.0", "license": "MIT", "dependencies": { "better-sqlite3": "^11.0.0", @@ -6532,26 +6554,6 @@ "node": ">= 0.6" } }, - "packages/client/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "packages/client/node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6700,6 +6702,8 @@ "cors": "^2.8.5", "dotenv": "^17.0.1", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "socket.io": "^4.8.1", "stream": "^0.0.3", "uuid": "^11.1.0" diff --git a/packages/client/README.md b/packages/client/README.md index f7d3944..ca04033 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -113,6 +113,7 @@ proxyhub -p 3000 --debug | `-t, --token ` | Token for tunnel protection | | `-i, --inspect` | Enable request inspector UI | | `--inspect-port ` | Port for inspector UI (default: port + 1000) | +| `-k, --auth-key ` | Authentication key for the ProxyHub server | | `-d, --debug` | Enable debug mode | | `-V, --version` | Output version number | | `-h, --help` | Display help | @@ -136,6 +137,8 @@ See the [self-hosting guide](https://github.com/cube-root/proxyhub#self-hosting) | `PROXYHUB_SOCKET_URL` | `https://connect.proxyhub.cloud` | ProxyHub server URL | | `PROXYHUB_SOCKET_PATH` | `/socket.io` | Socket.IO path | | `PROXYHUB_TOKEN` | - | Token for tunnel protection | +| `PROXYHUB_AUTH_KEY` | - | Authentication key for the server | +| `PROXYHUB_ALLOW_INSECURE` | - | Allow self-signed TLS certificates | ## How It Works @@ -144,6 +147,35 @@ See the [self-hosting guide](https://github.com/cube-root/proxyhub#self-hosting) 3. Incoming requests to that URL are forwarded through the WebSocket to the client 4. Client proxies them to your local server and streams responses back +## Security + +### Server Authentication + +If the ProxyHub server requires authentication, provide the shared key: + +```bash +proxyhub -p 3000 --auth-key your-secret-key + +# Or via environment variable +PROXYHUB_AUTH_KEY=your-secret-key proxyhub -p 3000 +``` + +### TLS Certificate Verification + +TLS certificate verification is enabled by default. To connect to servers with self-signed certificates: + +```bash +PROXYHUB_ALLOW_INSECURE=1 proxyhub -p 3000 +``` + +### Tunnel IDs + +Tunnel IDs are cryptographically random and persisted in `~/.proxyhub/tunnel-ids.json` for stability across restarts. + +The server also enforces rate limits: 5000 HTTP requests per 10-minute window and 30 WebSocket connections per minute per IP. These are configurable via server environment variables (`RATE_LIMIT_MAX_REQUESTS`, `RATE_LIMIT_WINDOW_MS`, `SOCKET_MAX_CONNECTIONS_PER_MINUTE`). + +See the [SECURITY.md](https://github.com/cube-root/proxyhub/blob/main/SECURITY.md) for full security documentation. + ## Links - [GitHub](https://github.com/cube-root/proxyhub) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index aa667b9..0108733 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -28,13 +28,17 @@ program .option('-t, --token ', 'Token for tunnel protection') .option('-i, --inspect', 'Enable request inspector', false) .option('-m, --mock', 'Enable mock mode', false) - .option('--inspect-port ', 'Port for inspector UI', parseInt); + .option('--inspect-port ', 'Port for inspector UI', parseInt) + .option('-k, --auth-key ', 'Authentication key for the ProxyHub server'); // Parse command line arguments program.parse(process.argv); // Get parsed options and check for env var fallback -const parsedOpts = program.opts() as ClientInitializationOptions & { port?: number }; +const parsedOpts = program.opts() as ClientInitializationOptions & { port?: number; authKey?: string }; +if (parsedOpts.authKey) { + process.env.PROXYHUB_AUTH_KEY = parsedOpts.authKey; +} const options: ClientInitializationOptions = { port: parsedOpts.port, debug: parsedOpts.debug, diff --git a/packages/client/src/lib/inspector.ts b/packages/client/src/lib/inspector.ts index b90faec..fbd67f3 100644 --- a/packages/client/src/lib/inspector.ts +++ b/packages/client/src/lib/inspector.ts @@ -1135,8 +1135,18 @@ export function startInspector(port: number, targetPort: number | undefined, opt mockEnabled = options?.mock || false; const app = express(); - app.use((_, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); + app.use((req, res, next) => { + const origin = req.headers.origin; + if (origin) { + try { + const host = new URL(origin).hostname; + if (host === 'localhost' || host === '127.0.0.1') { + res.header('Access-Control-Allow-Origin', origin); + } + } catch { + // Invalid origin — don't set CORS header + } + } res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type'); next(); diff --git a/packages/client/src/lib/socket.ts b/packages/client/src/lib/socket.ts index 7aec89d..81c3924 100644 --- a/packages/client/src/lib/socket.ts +++ b/packages/client/src/lib/socket.ts @@ -4,6 +4,9 @@ import { printError, printDebug } from "../utils/index.js"; import * as http from "http"; import { ResponseChannel } from "./tunnel.js"; import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; import { Transform, TransformCallback } from "stream"; import { initDb, @@ -156,17 +159,40 @@ const startTimeoutWarnings = (timeoutConfig: any) => { }, 60000); // Check every minute }; +const TUNNEL_IDS_PATH = path.join(os.homedir(), '.proxyhub', 'tunnel-ids.json'); + const generateStableTunnelId = (port: number): string => { - const machineInfo = process.env.USER || process.env.USERNAME || 'unknown'; - const portStr = port.toString(); - const processId = process.pid.toString(); + const key = String(port); + + // Try to load existing IDs + try { + const data = JSON.parse(fs.readFileSync(TUNNEL_IDS_PATH, 'utf-8')); + if (data[key] && typeof data[key] === 'string') { + return data[key]; + } + } catch { + // File doesn't exist or is invalid — generate new + } - const hash = crypto.createHash('md5') - .update(`${machineInfo}-${portStr}-${processId}`) - .digest('hex') - .substring(0, 12); + const id = crypto.randomBytes(16).toString('hex'); + + // Persist for stability across restarts + try { + const dir = path.dirname(TUNNEL_IDS_PATH); + fs.mkdirSync(dir, { recursive: true }); + let existing: Record = {}; + try { + existing = JSON.parse(fs.readFileSync(TUNNEL_IDS_PATH, 'utf-8')); + } catch { + // Fresh file + } + existing[key] = id; + fs.writeFileSync(TUNNEL_IDS_PATH, JSON.stringify(existing, null, 2)); + } catch { + // Non-fatal — ID still works for this session + } - return hash; + return id; }; const socketHandler = (option: ClientInitializationOptions) => { @@ -188,13 +214,13 @@ const socketHandler = (option: ClientInitializationOptions) => { } const socketPath = process?.env?.PROXYHUB_SOCKET_PATH ?? "/socket.io"; - const clientSecret = crypto.randomBytes(32).toString("hex"); + const clientSecret = process.env.PROXYHUB_AUTH_KEY || crypto.randomBytes(32).toString("hex"); const socket = io(socketUrl, { path: socketPath, transports: ["websocket"], secure: !socketUrl.includes("localhost"), - rejectUnauthorized: false, + rejectUnauthorized: !process.env.PROXYHUB_ALLOW_INSECURE, withCredentials: true, reconnection: true, reconnectionAttempts: 3, diff --git a/packages/server/package.json b/packages/server/package.json index 68be97d..17513f2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,14 +16,16 @@ "@types/express": "^5.0.3", "@types/node": "^24.0.10", "nodemon": "^3.1.10", - "ts-node": "^10.9.2", "release-it": "^18.1.2", + "ts-node": "^10.9.2", "typescript": "^5.8.3" }, "dependencies": { "cors": "^2.8.5", "dotenv": "^17.0.1", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "socket.io": "^4.8.1", "stream": "^0.0.3", "uuid": "^11.1.0" diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 267e8aa..df3d61b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,6 +3,8 @@ import express from "express"; import "dotenv/config"; import http from 'http'; import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; import crypto from 'crypto'; import SocketHandler from "./lib/socket"; import RequestMiddleware from "./middlewares/request"; @@ -22,15 +24,37 @@ function timingSafeEqual(a: string, b: string): boolean { return crypto.timingSafeEqual(bufA, bufB); } +const HOP_BY_HOP_HEADERS = new Set([ + 'transfer-encoding', + 'connection', + 'keep-alive', + 'upgrade', + 'te', + 'trailer', + 'proxy-authenticate', + 'proxy-authorization', +]); + const app = express(); const httpServer = http.createServer(app); const socketHandler = new SocketHandler(httpServer); socketHandler.start(); -app.use(express.urlencoded({ extended: true })); -app.use(express.json()); -app.use(cors()); +app.use(helmet()); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(express.json({ limit: '10mb' })); +const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',').map(o => o.trim()); +app.use(cors(allowedOrigins ? { origin: allowedOrigins } : undefined)); + +const httpLimiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '600000', 10), + max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000', 10), + skip: (req) => req.path === '/status' || req.path === '/health', + standardHeaders: true, + legacyHeaders: false, +}); +app.use(httpLimiter); app.get('/status', (req, res, next) => { const id = extractIdFromDomain(req.hostname); @@ -101,17 +125,22 @@ app.use("/", RequestMiddleware, (req, res) => { debug('Routing to tunnel:', socketId, '->', tunnelMapping.socketId); + const filteredHeaders: Record = { + ...req?.headers, + host: req?.headers?.host, + origin: req?.headers?.origin, + referer: req?.headers?.referer, + "if-none-match": undefined, + "if-modified-since": undefined, + "Cache-Control": "no-store", + }; + for (const header of HOP_BY_HOP_HEADERS) { + delete filteredHeaders[header]; + } + const requestChannel = new RequestChannel(tunnelMapping.socket, { method: req.method, - headers: { - ...req?.headers, - host: req?.headers?.host, - origin: req?.headers?.origin, - referer: req?.headers?.referer, - "if-none-match": undefined, - "if-modified-since": undefined, - "Cache-Control": "no-store", - }, + headers: filteredHeaders, path: req.url, body: req.body, clientIp: req.ip || req.socket?.remoteAddress || 'unknown', diff --git a/packages/server/src/lib/socket.ts b/packages/server/src/lib/socket.ts index 8fd6f4a..2dbdba6 100644 --- a/packages/server/src/lib/socket.ts +++ b/packages/server/src/lib/socket.ts @@ -2,6 +2,7 @@ import { Server } from "socket.io"; import http from 'http'; import os from 'os'; +import crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { debug } from '../utils/debug'; @@ -18,7 +19,9 @@ class SocketHandler { this.io = new Server(httpServer, { path: process?.env?.SOCKET_PATH ?? "/socket.io", cors: { - origin: "*", + origin: process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()) + : "*", methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], }, }); @@ -175,6 +178,55 @@ class SocketHandler { } start() { + // Socket.IO connection rate limiting + const socketMaxPerMinute = parseInt(process.env.SOCKET_MAX_CONNECTIONS_PER_MINUTE || '30', 10); + const connectionCounts = new Map(); + + // Cleanup stale entries every 5 minutes + const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of connectionCounts) { + if (now > entry.resetAt) { + connectionCounts.delete(ip); + } + } + }, 5 * 60 * 1000); + cleanupInterval.unref(); + + this.io.use((socket, next) => { + const ip = socket.handshake.address; + const now = Date.now(); + const entry = connectionCounts.get(ip); + + if (!entry || now > entry.resetAt) { + connectionCounts.set(ip, { count: 1, resetAt: now + 60000 }); + return next(); + } + + entry.count++; + if (entry.count > socketMaxPerMinute) { + return next(new Error('Too many connections. Please try again later.')); + } + next(); + }); + + // Socket.IO authentication middleware + const authKey = process.env.SOCKET_AUTH_KEY; + if (authKey) { + this.io.use((socket, next) => { + const clientSecret = socket.handshake.auth?.clientSecret; + if (!clientSecret || typeof clientSecret !== 'string') { + return next(new Error('Authentication required')); + } + const keyBuf = Buffer.from(authKey); + const secretBuf = Buffer.from(clientSecret); + if (keyBuf.length !== secretBuf.length || !crypto.timingSafeEqual(keyBuf, secretBuf)) { + return next(new Error('Authentication failed')); + } + next(); + }); + } + this.io.on("connection", (socket) => { console.log("Socket connected:", socket.id); this.setConnectionTimeout(socket);