diff --git a/.gitignore b/.gitignore index 397a63f..178e378 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ releases/ +workspace/ .env *.log diff --git a/README.md b/README.md index 6b45147..ce48899 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ DevSpace is a self-hosted MCP server that lets ChatGPT read, edit, search, and r ## Installation -DevSpace requires Node `>=20.12 <27`. Node 22 LTS is recommended. +DevSpace requires Node `>=22.19 <27`. Node 22.19 or newer is required; current Node 22 LTS or Node 24 is recommended. Install the DevSpace CLI: @@ -48,10 +48,16 @@ During setup, DevSpace asks for: - the local project folders ChatGPT is allowed to open through DevSpace - the local port, usually `7676` -- your public HTTPS base URL from Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or - another reverse proxy +- the local listen address, such as `127.0.0.1`, `100.64.0.2`, or `0.0.0.0` +- whether clients connect through localhost or a custom HTTPS URL -Use the public origin without `/mcp` during setup: +Choose **Localhost** to use `http://localhost:` automatically. Choose +**Custom HTTPS URL** to enter a Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, +reverse proxy, or other network origin. + +When an HTTPS URL already exists, `devspace init --force` defaults to keeping it +unchanged. Choose **Custom HTTPS URL** only when intentionally changing it. Enter +the public origin without `/mcp`: ```text https://your-tunnel-host.example.com @@ -68,6 +74,16 @@ the Owner password printed by `devspace init`. It is also stored in: Keep that password private. +The Owner password is only entered on the approval page. It is not sent with +every MCP request. After approval, DevSpace issues short-lived access tokens +and longer-lived refresh tokens. + +OAuth client registrations and hashed access and refresh tokens are persisted +in the DevSpace SQLite state database. Restarting `devspace serve` therefore +does not invalidate an already registered ChatGPT app. When upgrading from an +older build that stored OAuth state only in memory, remove and add the ChatGPT +app one final time so its client registration can be persisted. + ## Connect Your MCP Client The default local endpoint is: @@ -82,6 +98,24 @@ Most users should connect through a public HTTPS tunnel: https://your-tunnel-host.example.com/mcp ``` +To accept direct connections from another interface, set the bind address and +restart DevSpace: + +```bash +devspace config set host 100.64.0.2 +devspace serve +``` + +Always configure the MCP client with the full `/mcp` endpoint. The base URL +stored by DevSpace does not include `/mcp`, but the client-facing URL does: + +```text +https://your-tunnel-host.example.com/mcp +``` + +OAuth request logs preserve their original paths, so requests to `/register`, +`/authorize`, and `/token` are no longer incorrectly reported as `/`. + ## What ChatGPT Can Do Once connected, ChatGPT can open one of your approved project folders as a @@ -138,6 +172,7 @@ devspace doctor - [ChatGPT Coding Workflow](docs/chatgpt-coding-workflow.md) - [Configuration Reference](docs/configuration.md) - [Security Model](docs/security.md) +- [OAuth State and File Permissions](docs/oauth-state-and-security.md) - [Troubleshooting Gotchas](docs/gotchas.md) ## Philosophy @@ -177,3 +212,17 @@ npm test npm run build npm run start ``` + +To use the current checkout as the global `devspace` command: + +```bash +npm run typecheck +npm test +npm run build +npm uninstall -g @waishnav/devspace +npm install -g . +hash -r +``` + +The global npm entry then links back to this checkout. Future source changes +only require `npm run build`; reinstalling the global link is unnecessary. diff --git a/docs/configuration.md b/docs/configuration.md index 7107338..3f975d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,9 +23,20 @@ npx @waishnav/devspace init npx @waishnav/devspace serve npx @waishnav/devspace doctor npx @waishnav/devspace config get +npx @waishnav/devspace config set host 0.0.0.0 npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com ``` +Changing `host` takes effect after restarting `devspace serve`. Use a specific +interface address when possible. `0.0.0.0` exposes the service on every IPv4 +interface, so firewall access should be restricted accordingly. + +During `devspace init`, the port is followed by a separate local listen-address +step. The later URL step first offers to keep an existing URL unchanged, then offers +Localhost or Custom HTTPS URL. Localhost uses +`http://localhost:` automatically. Custom HTTPS URL waits for an explicit +tunnel or reverse-proxy origin. + ## Core Environment Variables | Variable | Purpose | @@ -37,7 +48,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | -| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_STATE_DIR` | Directory for private SQLite state. Defaults to `~/.local/share/devspace`. | ## OAuth diff --git a/docs/gotchas.md b/docs/gotchas.md index c5099dc..a529a76 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -15,7 +15,7 @@ If you installed globally, confirm npm's global bin directory is on `PATH`. ## Unsupported Node Version -DevSpace requires Node `>=20.12 <27`. +DevSpace requires Node `>=22.19 <27`. Check: @@ -23,7 +23,7 @@ Check: node --version ``` -Install Node 22 LTS with your preferred version manager such as `nvm`, `fnm`, or +Install Node 22.19 or newer with your preferred version manager such as `nvm`, `fnm`, or `mise`. ## `better-sqlite3` Could Not Load @@ -100,6 +100,27 @@ Use this only for intentional local debugging: DEVSPACE_ALLOWED_HOSTS="*" npx @waishnav/devspace serve ``` +## OAuth Request Paths In Logs + +Configure the ChatGPT app with the full MCP endpoint: + +```text +https://your-host.example.com/mcp +``` + +The public base URL stored by DevSpace is the origin without `/mcp`, while MCP +clients connect to `/mcp`. Older DevSpace versions could log OAuth router +requests such as `/register` or `/token` as `/` because Express rewrote the +mounted router path before the response log was emitted. Current versions log +the original request path. + +## OAuth `invalid_client` After Restart + +DevSpace persists dynamically registered OAuth clients and hashed access and +refresh tokens in its SQLite state database. If upgrading from a version that +kept OAuth state only in memory, remove and add the ChatGPT app one final time +to obtain a persisted client ID. Later DevSpace restarts should reuse it. + ## OAuth Redirect Host Rejected By default, DevSpace allows redirects for: diff --git a/docs/oauth-state-and-security.md b/docs/oauth-state-and-security.md new file mode 100644 index 0000000..33b29a3 --- /dev/null +++ b/docs/oauth-state-and-security.md @@ -0,0 +1,238 @@ +# OAuth State, File Permissions, and URL Setup + +This note documents how DevSpace stores OAuth state, why that state survives +server restarts, which values remain sensitive, and how the CLI chooses the +client-facing URL. + +## Files And Directories + +DevSpace uses two private locations by default: + +```text +~/.devspace/ + config.json + auth.json + +~/.local/share/devspace/ + devspace.sqlite + devspace.sqlite-wal + devspace.sqlite-shm +``` + +`DEVSPACE_CONFIG_DIR` changes the first directory. `DEVSPACE_STATE_DIR` changes +the SQLite state directory. + +On POSIX systems DevSpace enforces these permissions whenever it writes config +or opens the state database: + +```text +~/.devspace 0700 +~/.devspace/config.json 0600 +~/.devspace/auth.json 0600 +~/.local/share/devspace 0700 +devspace.sqlite 0600 +devspace.sqlite-wal 0600 +devspace.sqlite-shm 0600 +``` + +This also repairs permissions on files and directories that already exist. +Windows does not use these POSIX mode checks; filesystem ACLs control access +there. + +## What Is Stored + +### Configuration + +`config.json` contains operational settings such as the bind host, port, +allowed project roots, and public base URL. It does not contain the Owner +password. + +`auth.json` contains the Owner password. DevSpace needs the original value to +verify the password entered on its approval page, so this file is sensitive and +must remain private. + +### OAuth clients + +The `oauth_clients` SQLite table stores dynamically registered MCP client +metadata, including: + +- generated `client_id` +- client name +- allowed redirect URIs +- grant and response types +- token endpoint authentication method +- registration timestamp + +DevSpace accepts public OAuth clients only, advertises `none` as its token and +revocation endpoint authentication method, and strips client-secret fields +before persistence. This metadata is not an access token, but it can reveal +which client and redirect URI are configured. + +### OAuth tokens + +The `oauth_tokens` table stores: + +- SHA-256 token hash +- access or refresh token type +- associated client ID +- scopes +- resource URL +- creation and expiration timestamps + +Raw access and refresh token values are never written to SQLite. Tokens are +random 256-bit values, so a database reader cannot practically recover a token +from its SHA-256 hash. Expired token rows are removed when the OAuth store is +opened. + +### Workspace state + +The same SQLite database also contains workspace session metadata and loaded +agent-instruction snapshots. This can include local project paths and the +contents of loaded `AGENTS.md` or similar instruction files. The database +should therefore be treated as private even though OAuth tokens are hashed. + +## Why Restarting Used To Break ChatGPT + +ChatGPT can use dynamic client registration. It calls `/register` once, receives +a generated `client_id`, and reuses that ID for the app instance. + +Older DevSpace builds stored the client registration and tokens only in memory. +Restarting the server erased them, while ChatGPT continued sending the old +`client_id`. DevSpace then returned: + +```text +error: invalid_client +error_description: Invalid client_id +``` + +Current builds persist the registered client and hashed token state in SQLite. +The same client ID remains valid after a normal server restart. + +When upgrading from an older in-memory build, remove and add the ChatGPT app one +final time. That creates a new client registration that can be persisted. + +## Owner Password And Bearer Tokens + +The Owner password is used only on the DevSpace approval page. It is not sent on +every MCP request. + +After approval, DevSpace issues: + +- a short-lived access token, one hour by default +- a longer-lived refresh token, 30 days by default + +ChatGPT sends the access token as a bearer token and uses the refresh token to +obtain a new access token. Refresh tokens are rotated when used. + +## URL Selection In `devspace init` + +When a public URL already exists, the setup flow first offers: + +```text +Keep existing: https://your-current-host.example.com +``` + +Selecting it preserves the HTTPS/OAuth address while allowing the local bind +address or port to change. New setups and intentional URL changes offer: + +```text +1. Localhost +2. Custom HTTPS URL +``` + +Localhost automatically sets: + +```text +http://localhost: +``` + +Custom HTTPS URL always opens a text input and waits for the user. The displayed URLs +are examples only. The user enters a tunnel or reverse-proxy HTTPS origin, for example: + +```text +https://devspace.example.com +``` + +The stored public base URL is an origin without `/mcp`. The URL configured in +ChatGPT must include the MCP path: + +```text +https://devspace.example.com/mcp +``` + +The local listen address is requested immediately after the port. The bind host +and public URL are separate settings. A server may bind to +`100.64.0.2`, for example, while clients connect through an HTTPS domain. + +## Logging + +OAuth router requests retain their original path in request logs. Requests to +`/register`, `/authorize`, and `/token` should no longer appear incorrectly as +`/`. + +Request logs do not intentionally include bearer tokens or the Owner password. +Avoid enabling shell-command logging when commands may contain secrets. + +## Threat Boundaries + +The permission and hashing changes reduce local disclosure risk, but they do +not make a compromised account safe: + +- a process running as the same OS user can read private files +- root or an administrator can read the files +- malware in the user session can access the database and Owner password +- backups can copy sensitive configuration and workspace metadata +- an exposed shell tool has the permissions of the DevSpace OS user + +Use a dedicated low-privilege OS account when stronger isolation is required. +Restrict tunnel access, keep allowed roots narrow, and protect backups. + +## Dependency Audit + +The production dependency tree is checked with: + +```bash +npm audit --omit=dev +``` + +The security review updated `-works/pi-coding-agent` to `0.79.10`, +which resolves the reported `undici`, `protobufjs`, and `ws` advisories. This +secure dependency tree requires Node `>=22.19`. + +## Verification Commands + +Check paths and permissions: + +```bash +devspace doctor +ls -ld ~/.devspace ~/.local/share/devspace +ls -l ~/.devspace/config.json ~/.devspace/auth.json +ls -l ~/.local/share/devspace/devspace.sqlite* +``` + +Expected POSIX permissions are `drwx------` for the directories and `-rw-------` +for the files. + +Check connectivity: + +```bash +curl http://127.0.0.1:7676/healthz +curl https://your-host.example.com/healthz +``` + +The MCP endpoint should reject unauthenticated requests with HTTP 401 and a +`WWW-Authenticate` header: + +```bash +curl -i https://your-host.example.com/mcp +``` + +## Backup And Reset + +Stop DevSpace before taking a simple file-level backup of the SQLite database, +or use an SQLite-aware backup method while it is running. Include the WAL file +if copying a live WAL-mode database. + +Deleting the state database removes persisted workspace and OAuth state. After +such a reset, ChatGPT must register and authorize the app again. Deleting +`auth.json` removes the Owner password configuration and requires setup again. diff --git a/docs/security.md b/docs/security.md index 53fe027..aa4795e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -43,6 +43,11 @@ reach. When an MCP client connects, DevSpace shows an approval page. Enter the Owner password only when you intentionally want that client to access this server. +The Owner password is entered only on the DevSpace authorization page. It is not +the bearer token attached to every MCP request. DevSpace issues short-lived +access tokens and longer-lived refresh tokens after approval, and stores only +their hashes in the SQLite state database. + For env-driven deployments, set a long random value: ```bash @@ -98,3 +103,7 @@ By default, DevSpace logs requests and tool calls. Shell command previews are disabled unless `DEVSPACE_LOG_SHELL_COMMANDS=1`. Do not enable shell command logging if commands may contain secrets. + +On POSIX systems DevSpace enforces mode `0700` on its config and state +directories and `0600` on config, auth, SQLite, WAL, and SHM files. See +[OAuth State, File Permissions, and URL Setup](oauth-state-and-security.md). diff --git a/docs/setup.md b/docs/setup.md index 8efbcdc..845206e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -5,7 +5,7 @@ projects through DevSpace. ## Requirements -- Node `>=20.12 <27`; Node 22 LTS is recommended +- Node `>=22.19 <27`; Node 22.19 or newer is recommended - npm - Git - Bash, including Git Bash or WSL on Windows @@ -53,16 +53,33 @@ The local MCP URL is: http://127.0.0.1:7676/mcp ``` -### Public Base URL +### Local Listen Address -Start your tunnel or reverse proxy before entering this value. Point the tunnel -at: +After the port, `devspace init` asks which local address the server should bind +to. Examples: ```text -http://127.0.0.1:7676 +127.0.0.1 local machine only +100.64.0.2 a specific Tailscale or network interface +0.0.0.0 every IPv4 interface ``` -Enter the public origin without `/mcp`: +This does not change the public HTTPS/OAuth URL. + +### Public Base URL + +If a public URL is already configured, `devspace init --force` defaults to +keeping it unchanged. This is the normal choice when only changing the local +listen address. For a new setup or an intentional URL change, the choices are: + +- **Localhost** uses `http://localhost:` and needs no additional URL input. +- **Custom HTTPS URL** waits for a tunnel or reverse-proxy HTTPS origin. + +A direct LAN or Tailscale address belongs in the earlier local listen-address +step, not in the OAuth public URL field. + +For a custom URL, point the tunnel or reverse proxy at the local server and +enter the client-facing origin without `/mcp`: ```text https://your-tunnel-host.example.com diff --git a/package-lock.json b/package-lock.json index c112ebc..dc3b60b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,15 +70,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.4.tgz", - "integrity": "sha512-PthzVzM5m4XH/hrU+2fVjuwuH5M4eMFWbd0NCRScH14XKpwlPc8/Fh6JDz0jQb5kTBT9oQT183YLTHVVulFL9A==", + "version": "0.79.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.10.tgz", + "integrity": "sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.4", - "@earendil-works/pi-ai": "^0.79.4", - "@earendil-works/pi-tui": "^0.79.4", + "@earendil-works/pi-agent-core": "^0.79.10", + "@earendil-works/pi-ai": "^0.79.10", + "@earendil-works/pi-tui": "^0.79.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -92,7 +92,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -541,11 +541,11 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.4.tgz", + "version": "0.79.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.10.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -555,14 +555,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.4.tgz", + "version": "0.79.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.10.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -578,12 +579,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.4.tgz", + "version": "0.79.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.10.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -687,6 +688,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -703,6 +707,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -719,6 +726,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -735,6 +745,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -751,6 +764,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -793,14 +809,23 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { @@ -815,6 +840,24 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -834,9 +877,9 @@ "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { @@ -854,12 +897,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1451,15 +1488,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { @@ -1637,24 +1674,23 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1759,9 +1795,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -1798,9 +1834,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index f8d3903..330e6ff 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/server.js", "engines": { - "node": ">=20.12 <27" + "node": ">=22.19 <27" }, "bin": { "devspace": "dist/cli.js" @@ -21,11 +21,11 @@ }, "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", - "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json", + "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"", "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts", + "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/logger.test.ts && tsx src/security-permissions.test.ts && tsx src/oauth-provider.test.ts && tsx src/review-checkpoints.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index 0e0147b..b442937 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { createRequire } from "node:module"; +import { isIP } from "node:net"; import { stdin as input, stdout as output } from "node:process"; import { resolve } from "node:path"; import * as prompts from "@clack/prompts"; @@ -17,7 +18,7 @@ import { expandHomePath } from "./roots.js"; type Command = "serve" | "init" | "doctor" | "config" | "help"; const require = createRequire(import.meta.url); -const SUPPORTED_NODE_RANGE = ">=20.12 <27"; +const SUPPORTED_NODE_RANGE = ">=22.19 <27"; async function main(argv: string[]): Promise { assertSupportedNode(); @@ -105,27 +106,72 @@ async function runInit({ force }: { force: boolean }): Promise { }); const port = Number(portAnswer); - prompts.note( - [ - "DevSpace needs a public base URL so ChatGPT or Claude can reach this MCP server.", - "Create a tunnel or reverse proxy with Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS proxy.", - "Paste the public origin here, without /mcp.", - "", - "Example: https://your-tunnel-host.example.com", - ].join("\n"), - "Public URL required", - ); - const publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.publicBaseUrl - ? `What is the public base URL? Press Enter to keep ${files.config.publicBaseUrl}` - : "What is the public base URL?", - placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.publicBaseUrl ?? "", - validate: validateRequiredPublicBaseUrl, - })); + const defaultHost = files.config.host ?? "127.0.0.1"; + const host = await textPrompt({ + message: `Which local address should DevSpace listen on? Press Enter to use ${defaultHost}`, + placeholder: defaultHost, + defaultValue: defaultHost, + validate: validateHost, + }); + + const existingPublicUrl = files.config.publicBaseUrl ?? ""; + const publicUrlOptions = [ + ...(existingPublicUrl + ? [{ + value: "keep", + label: `Keep existing: ${existingPublicUrl}`, + hint: "Recommended when only changing the listen address", + }] + : []), + { + value: "localhost", + label: "1. Localhost", + hint: "http://localhost:" + port, + }, + { + value: "custom", + label: "2. Custom HTTPS URL", + hint: "Public HTTPS origin for OAuth and MCP", + }, + ]; + const publicUrlMode = await prompts.select({ + message: "Which public URL should OAuth and MCP clients use?", + options: publicUrlOptions, + initialValue: existingPublicUrl ? "keep" : "localhost", + }); + if (prompts.isCancel(publicUrlMode)) throw new SetupCancelledError(); + + let publicBaseUrl: string; + if (publicUrlMode === "keep") { + publicBaseUrl = existingPublicUrl; + } else if (publicUrlMode === "localhost") { + publicBaseUrl = "http://localhost:" + port; + } else { + prompts.note( + [ + "Enter the public HTTPS origin clients will use, without /mcp.", + "Set LAN, Tailscale, or 0.0.0.0 binding in the local listen-address step.", + "Example:", + " https://your-tunnel-host.example.com", + ].join("\n"), + "Custom HTTPS URL", + ); + publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ + message: existingPublicUrl && !isLocalPublicBaseUrl(existingPublicUrl) + ? "What is the custom URL? Press Enter to keep " + existingPublicUrl + : "What is the custom URL?", + placeholder: existingPublicUrl && !isLocalPublicBaseUrl(existingPublicUrl) + ? existingPublicUrl + : "https://your-tunnel-host.example.com", + defaultValue: existingPublicUrl && !isLocalPublicBaseUrl(existingPublicUrl) + ? existingPublicUrl + : "", + validate: validateRequiredPublicBaseUrl, + })); + } const config: DevspaceUserConfig = { - host: files.config.host ?? "127.0.0.1", + host, port, allowedRoots, publicBaseUrl, @@ -233,19 +279,27 @@ function runConfigCommand(args: string[]): void { if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } - if (key !== "publicBaseUrl") { - throw new Error("Only `devspace config set publicBaseUrl ` is supported right now."); - } - const value = rest.join(" ").trim(); if (!value) { - throw new Error("Missing publicBaseUrl value."); + throw new Error("Missing config value."); } - writeDevspaceConfig({ - ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); + let update: Partial; + switch (key) { + case "host": { + const validationError = validateHost(value); + if (validationError) throw new Error(validationError); + update = { host: value }; + break; + } + case "publicBaseUrl": + update = { publicBaseUrl: normalizeOptionalPublicBaseUrl(value) }; + break; + default: + throw new Error("Supported config keys: host, publicBaseUrl."); + } + + writeDevspaceConfig({ ...files.config, ...update }); console.log(`Updated ${files.configPath}`); } @@ -260,6 +314,7 @@ function printHelp(): void { " devspace init Create or update ~/.devspace/config.json and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", + " devspace config set host
", " devspace config set publicBaseUrl ", "", "For temporary tunnels:", @@ -284,6 +339,16 @@ function normalizePublicBaseUrl(value: string): string { return parsed.toString().replace(/\/$/, ""); } +function isLocalPublicBaseUrl(value: string): boolean { + if (!value) return true; + try { + const hostname = new URL(value).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + } catch { + return false; + } +} + type TextPromptOptions = Omit[0], "validate"> & { defaultValue: string; validate?: (value: string | undefined) => string | Error | undefined; @@ -306,6 +371,20 @@ function validatePort(value: string | undefined): string | undefined { : "Enter a port between 1 and 65535."; } +function validateHost(value: string | undefined): string | undefined { + const host = value?.trim() ?? ""; + if (!host) return "Enter a bind address such as 127.0.0.1, 0.0.0.0, or ::."; + if (isIP(host)) return undefined; + if ( + host.length <= 253 + && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(host) + && host.split(".").every((label) => label.length <= 63 && !label.startsWith("-") && !label.endsWith("-")) + ) { + return undefined; + } + return "Enter an IP address or hostname without a protocol, port, or path."; +} + function validateRequiredPublicBaseUrl(value: string | undefined): string | undefined { const trimmed = value?.trim() ?? ""; if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; @@ -316,9 +395,14 @@ function validateRequiredPublicBaseUrl(value: string | undefined): string | unde function validatePublicBaseUrl(value: string): string | undefined { try { const parsed = new URL(value); - return parsed.protocol === "http:" || parsed.protocol === "https:" - ? undefined - : "Use an http or https URL."; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "Use an http or https URL."; + } + const localHost = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + if (parsed.protocol !== "https:" && !localHost) { + return "Non-local public URLs must use https. Set the local listen address in the previous step."; + } + return undefined; } catch { return "Enter a valid URL, for example https://your-tunnel-host.example.com."; } @@ -332,7 +416,7 @@ function assertSupportedNode(): void { `DevSpace requires Node ${SUPPORTED_NODE_RANGE}.`, `Current Node: ${process.version}`, "", - "Install Node 22 LTS or use a version manager such as nvm, fnm, or mise.", + "Install Node 22.19 or newer, or use a version manager such as nvm, fnm, or mise.", ].join("\n"), ); } diff --git a/src/db/client.ts b/src/db/client.ts index e3057c8..5688976 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; @@ -18,10 +18,16 @@ export function databasePath(stateDir: string): string { } export function openDatabase(stateDir: string): DatabaseHandle { - mkdirSync(stateDir, { recursive: true }); - const sqlite = new Database(databasePath(stateDir)); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + setPrivateMode(stateDir, 0o700); + + const filePath = databasePath(stateDir); + const sqlite = new Database(filePath); + setPrivateMode(filePath, 0o600); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("foreign_keys = ON"); + setPrivateMode(filePath + "-wal", 0o600); + setPrivateMode(filePath + "-shm", 0o600); return { sqlite, @@ -30,6 +36,10 @@ export function openDatabase(stateDir: string): DatabaseHandle { }; } +function setPrivateMode(path: string, mode: number): void { + if (process.platform !== "win32" && existsSync(path)) chmodSync(path, mode); +} + function createDrizzleDatabase(sqlite: SqliteDatabase) { return drizzle(sqlite, { schema }); } diff --git a/src/logger.test.ts b/src/logger.test.ts new file mode 100644 index 0000000..b0d52b9 --- /dev/null +++ b/src/logger.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import type { Request } from "express"; +import { requestPath } from "./logger.js"; + +assert.equal( + requestPath({ + originalUrl: "/register?source=chatgpt", + path: "/", + url: "/", + } as Request), + "/register", +); +assert.equal( + requestPath({ originalUrl: "", path: "/mcp", url: "/mcp" } as Request), + "/mcp", +); diff --git a/src/logger.ts b/src/logger.ts index c183ff6..1885f9e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -65,7 +65,8 @@ export function requestIp(req: Request, trustProxy: boolean): string | undefined } export function requestPath(req: Request): string { - return req.path || req.url.split("?")[0] || req.url; + const originalPath = req.originalUrl?.split("?")[0]; + return originalPath || req.path || req.url.split("?")[0] || req.url; } export function sessionIdPrefix(sessionId: string | undefined): string | undefined { diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts new file mode 100644 index 0000000..b5076b5 --- /dev/null +++ b/src/oauth-provider.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SqliteOAuthStore } from "./oauth-provider.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); + +try { + const first = new SqliteOAuthStore(root, ["chatgpt.com"]); + const client = first.registerClient({ + client_name: "ChatGPT", + redirect_uris: ["https://chatgpt.com/connector/oauth/callback"], + token_endpoint_auth_method: "none", + }); + assert.equal(client.client_secret, undefined); + assert.equal(client.client_secret_expires_at, undefined); + assert.throws( + () => first.registerClient({ + client_name: "Confidential client", + redirect_uris: ["https://chatgpt.com/connector/oauth/callback"], + token_endpoint_auth_method: "client_secret_post", + client_secret: "must-not-be-stored", + }), + /only supports public OAuth clients/, + ); + + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + first.saveToken("access-token", "access", { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt, + resource: new URL("https://devspace.example.com/mcp"), + }); + first.saveToken("refresh-token", "refresh", { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt, + }); + first.close(); + + const second = new SqliteOAuthStore(root, ["chatgpt.com"]); + assert.deepEqual(second.getClient(client.client_id), client); + assert.deepEqual(second.getToken("access-token", "access"), { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt, + resource: new URL("https://devspace.example.com/mcp"), + }); + assert.deepEqual(second.getToken("refresh-token", "refresh"), { + clientId: client.client_id, + scopes: ["devspace"], + expiresAt, + resource: undefined, + }); + second.deleteToken("refresh-token"); + assert.equal(second.getToken("refresh-token", "refresh"), undefined); + second.close(); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 9bb442f..a9c80f0 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -10,6 +10,7 @@ import type { OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; export interface OAuthConfig { ownerToken: string; @@ -25,16 +26,7 @@ interface AuthorizationCodeRecord { expiresAtMs: number; } -interface AccessTokenRecord { - token: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: URL; -} - -interface RefreshTokenRecord { - token: string; +export interface OAuthTokenRecord { clientId: string; scopes: string[]; expiresAt: number; @@ -138,49 +130,143 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole return allowedHosts.includes(parsed.hostname); } -export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { - private readonly clients = new Map(); +export class SqliteOAuthStore implements OAuthRegisteredClientsStore { + private readonly database: DatabaseHandle; - constructor(private readonly allowedRedirectHosts: string[]) {} + constructor( + stateDir: string, + private readonly allowedRedirectHosts: string[], + ) { + this.database = openDatabase(stateDir); + this.database.sqlite.exec(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_tokens ( + token_hash TEXT PRIMARY KEY, + token_type TEXT NOT NULL, + client_id TEXT NOT NULL, + scopes_json TEXT NOT NULL, + expires_at INTEGER NOT NULL, + resource TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS oauth_tokens_expiry_idx + ON oauth_tokens (expires_at); + `); + this.database.sqlite + .prepare("DELETE FROM oauth_tokens WHERE expires_at < ?") + .run(Math.floor(Date.now() / 1000)); + } getClient(clientId: string): OAuthClientInformationFull | undefined { - return this.clients.get(clientId); + const row = this.database.sqlite + .prepare("SELECT client_json FROM oauth_clients WHERE client_id = ?") + .get(clientId) as { client_json: string } | undefined; + return row ? JSON.parse(row.client_json) as OAuthClientInformationFull : undefined; } registerClient( client: Omit, ): OAuthClientInformationFull { + if (client.token_endpoint_auth_method && client.token_endpoint_auth_method !== "none") { + throw new InvalidRequestError("DevSpace only supports public OAuth clients"); + } if (!client.redirect_uris.every((uri) => redirectHostAllowed(uri, this.allowedRedirectHosts))) { throw new InvalidRequestError("Client redirect_uri is not allowed for this DevSpace server"); } const now = Math.floor(Date.now() / 1000); + const { client_secret: _clientSecret, client_secret_expires_at: _secretExpiry, ...clientMetadata } = client; const registered: OAuthClientInformationFull = { - ...client, + ...clientMetadata, client_id: `devspace-${randomUUID()}`, client_id_issued_at: now, - token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none", + token_endpoint_auth_method: "none", grant_types: client.grant_types ?? ["authorization_code", "refresh_token"], response_types: client.response_types ?? ["code"], }; - this.clients.set(registered.client_id, registered); + this.database.sqlite + .prepare(` + INSERT INTO oauth_clients (client_id, client_json, created_at) + VALUES (?, ?, ?) + ON CONFLICT(client_id) DO UPDATE SET client_json = excluded.client_json + `) + .run(registered.client_id, JSON.stringify(registered), now); return registered; } + + getToken(token: string, tokenType: "access" | "refresh"): OAuthTokenRecord | undefined { + const row = this.database.sqlite + .prepare(` + SELECT client_id, scopes_json, expires_at, resource + FROM oauth_tokens + WHERE token_hash = ? AND token_type = ? AND expires_at >= ? + `) + .get(hashToken(token), tokenType, Math.floor(Date.now() / 1000)) as { + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + } | undefined; + if (!row) return undefined; + return { + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ? new URL(row.resource) : undefined, + }; + } + + saveToken( + token: string, + tokenType: "access" | "refresh", + record: OAuthTokenRecord, + ): void { + this.database.sqlite + .prepare(` + INSERT INTO oauth_tokens ( + token_hash, token_type, client_id, scopes_json, expires_at, resource, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + .run( + hashToken(token), + tokenType, + record.clientId, + JSON.stringify(record.scopes), + record.expiresAt, + record.resource?.href ?? null, + Math.floor(Date.now() / 1000), + ); + } + + deleteToken(token: string): void { + this.database.sqlite + .prepare("DELETE FROM oauth_tokens WHERE token_hash = ?") + .run(hashToken(token)); + } + + close(): void { + this.database.close(); + } } export class SingleUserOAuthProvider implements OAuthServerProvider { readonly clientsStore: OAuthRegisteredClientsStore; private readonly codes = new Map(); - private readonly accessTokens = new Map(); - private readonly refreshTokens = new Map(); + private readonly store: SqliteOAuthStore; private readonly resourceServerUrl: URL; constructor( private readonly config: OAuthConfig, resourceServerUrl: URL, + stateDir: string, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); - this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts); + this.store = new SqliteOAuthStore(stateDir, config.allowedRedirectHosts); + this.clientsStore = this.store; } async authorize( @@ -269,8 +355,8 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { scopes?: string[], resource?: URL, ): Promise { - const record = this.refreshTokens.get(hashToken(refreshToken)); - if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { + const record = this.store.getToken(refreshToken, "refresh"); + if (!record || record.clientId !== client.client_id) { throw new InvalidGrantError("Invalid refresh token"); } if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { @@ -282,13 +368,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new AccessDeniedError("Refresh token cannot grant requested scopes"); } - this.refreshTokens.delete(hashToken(refreshToken)); + this.store.deleteToken(refreshToken); return this.issueTokens(client.client_id, requestedScopes, resource ?? record.resource); } async verifyAccessToken(token: string): Promise { - const record = this.accessTokens.get(hashToken(token)); - if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) { + const record = this.store.getToken(token, "access"); + if (!record) { throw new InvalidTokenError("Invalid or expired access token"); } @@ -302,9 +388,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { } async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { - const hashed = hashToken(request.token); - this.accessTokens.delete(hashed); - this.refreshTokens.delete(hashed); + this.store.deleteToken(request.token); } private validCodeRecord( @@ -325,15 +409,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const accessExpiresAt = now + this.config.accessTokenTtlSeconds; const refreshExpiresAt = now + this.config.refreshTokenTtlSeconds; - this.accessTokens.set(hashToken(accessToken), { - token: accessToken, + this.store.saveToken(accessToken, "access", { clientId, scopes, expiresAt: accessExpiresAt, resource, }); - this.refreshTokens.set(hashToken(refreshToken), { - token: refreshToken, + this.store.saveToken(refreshToken, "refresh", { clientId, scopes, expiresAt: refreshExpiresAt, diff --git a/src/security-permissions.test.ts b/src/security-permissions.test.ts new file mode 100644 index 0000000..1262dcd --- /dev/null +++ b/src/security-permissions.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises"; +import { platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import { databasePath, openDatabase } from "./db/client.js"; +import { loadDevspaceFiles, writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; + +if (platform() !== "win32") { + const root = await mkdtemp(join(tmpdir(), "devspace-permissions-test-")); + try { + const stateDir = join(root, "state"); + const database = openDatabase(stateDir); + database.sqlite.exec("CREATE TABLE permission_test (id INTEGER); INSERT INTO permission_test VALUES (1)"); + + assert.equal((await stat(stateDir)).mode & 0o777, 0o700); + assert.equal((await stat(databasePath(stateDir))).mode & 0o777, 0o600); + assert.equal((await stat(databasePath(stateDir) + "-wal")).mode & 0o777, 0o600); + assert.equal((await stat(databasePath(stateDir) + "-shm")).mode & 0o777, 0o600); + database.close(); + + const configDir = join(root, "config"); + await mkdir(configDir, { mode: 0o777 }); + const env = { DEVSPACE_CONFIG_DIR: configDir }; + const configPath = writeDevspaceConfig({ port: 7676 }, env); + const authPath = writeDevspaceAuth({ ownerToken: "test-owner-token-that-is-long-enough" }, env); + + await chmod(configDir, 0o777); + await chmod(configPath, 0o666); + await chmod(authPath, 0o666); + loadDevspaceFiles(env); + + assert.equal((await stat(configDir)).mode & 0o777, 0o700); + assert.equal((await stat(configPath)).mode & 0o777, 0o600); + assert.equal((await stat(authPath)).mode & 0o777, 0o600); + + await chmod(configDir, 0o777); + await chmod(configPath, 0o666); + await chmod(authPath, 0o666); + writeDevspaceConfig({ port: 8787 }, env); + writeDevspaceAuth({ ownerToken: "updated-owner-token-that-is-long-enough" }, env); + + assert.equal((await stat(configDir)).mode & 0o777, 0o700); + assert.equal((await stat(configPath)).mode & 0o777, 0o600); + assert.equal((await stat(authPath)).mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/src/server.ts b/src/server.ts index 9c554dc..3a9a41b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import { access, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; -import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { createOAuthMetadata, mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; @@ -1275,7 +1275,7 @@ export function createServer(config = loadConfig()): RunningServer { const transports = new Map(); const mcpUrl = new URL("/mcp", config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); - const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl); + const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir); const bearerAuth = requireBearerAuth({ verifier: oauthProvider, requiredScopes: [config.oauth.scopes[0] ?? "devspace"], @@ -1312,16 +1312,21 @@ export function createServer(config = loadConfig()): RunningServer { next(); }); - app.use( - mcpAuthRouter({ - provider: oauthProvider, - issuerUrl: new URL(config.publicBaseUrl), - baseUrl: new URL(config.publicBaseUrl), - resourceServerUrl, - scopesSupported: config.oauth.scopes, - resourceName: "DevSpace", - }), - ); + const authRouterOptions = { + provider: oauthProvider, + issuerUrl: new URL(config.publicBaseUrl), + baseUrl: new URL(config.publicBaseUrl), + resourceServerUrl, + scopesSupported: config.oauth.scopes, + resourceName: "DevSpace", + }; + const oauthMetadata = createOAuthMetadata(authRouterOptions); + oauthMetadata.token_endpoint_auth_methods_supported = ["none"]; + oauthMetadata.revocation_endpoint_auth_methods_supported = ["none"]; + app.get("/.well-known/oauth-authorization-server", (_req, res) => { + res.json(oauthMetadata); + }); + app.use(mcpAuthRouter(authRouterOptions)); app.options("/mcp-app-assets/{*asset}", (_req, res) => { setAssetHeaders(res); diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c51..be035d3 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { + chmodSync, existsSync, mkdirSync, readFileSync, @@ -52,6 +53,7 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac const authPath = join(dir, "auth.json"); const configExists = existsSync(configPath); const authExists = existsSync(authPath); + secureExistingConfigFiles(dir, configPath, authPath, configExists, authExists); return { dir, @@ -69,7 +71,7 @@ export function writeDevspaceConfig( env: NodeJS.ProcessEnv = process.env, ): string { const filePath = devspaceConfigPath(env); - mkdirSync(devspaceConfigDir(env), { recursive: true }); + ensurePrivateDirectory(devspaceConfigDir(env)); writeJsonFile(filePath, config, 0o600); return filePath; } @@ -79,7 +81,7 @@ export function writeDevspaceAuth( env: NodeJS.ProcessEnv = process.env, ): string { const filePath = devspaceAuthPath(env); - mkdirSync(devspaceConfigDir(env), { recursive: true }); + ensurePrivateDirectory(devspaceConfigDir(env)); writeJsonFile(filePath, auth, 0o600); return filePath; } @@ -99,4 +101,23 @@ function readJsonFile(filePath: string): T { function writeJsonFile(filePath: string, value: unknown, mode: number): void { writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); + if (process.platform !== "win32") chmodSync(filePath, mode); +} + +function ensurePrivateDirectory(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") chmodSync(path, 0o700); +} + +function secureExistingConfigFiles( + dir: string, + configPath: string, + authPath: string, + configExists: boolean, + authExists: boolean, +): void { + if (process.platform === "win32" || !existsSync(dir)) return; + chmodSync(dir, 0o700); + if (configExists) chmodSync(configPath, 0o600); + if (authExists) chmodSync(authPath, 0o600); }