diff --git a/nginx.conf.template b/nginx.conf.template index 37797b6..be2e29c 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -13,6 +13,7 @@ http { sendfile on; + # See https://nginx.org/en/docs/http/websocket.html. map $http_upgrade $connection_upgrade { default upgrade; '' close; @@ -24,12 +25,27 @@ http { # Dynamic per-user routes (written by main server) include $NGINX_CONF_DIR/user-routes/*.conf; - + # Block unmatched /_vs/* — only allow user routes installed above location /_vs/ { return 404; } + # Target of `auth_request` in VSCode user routes. + # See https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/. + # Mostly identical to the blanket forwarding route below, + # but importantly skips Upgrade/Connection headers: + # WebSocket connections to VSCode are broken if we forward these. + location /api/auth-vsc/ { + # Can only be reached by internal requests + internal; + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + } + # Everything else -> main server location / { proxy_pass http://127.0.0.1:3002; diff --git a/src/app/api/auth-vsc/[sessionId]/route.ts b/src/app/api/auth-vsc/[sessionId]/route.ts new file mode 100644 index 0000000..c9796b6 --- /dev/null +++ b/src/app/api/auth-vsc/[sessionId]/route.ts @@ -0,0 +1,13 @@ +import { requireAuth } from '@/lib/server/actions' +import { getEditorSessionManager } from '@/lib/server/editorSessions' +import { forbidden } from 'next/navigation' + +/** Queried by Nginx to ensure the sending user can access the given editor session. +Any 2xx response counts for successful authentication, +whereas other codes cause Nginx to reject the original request. */ +export async function GET(_req: Request, { params }: { params: Promise<{ sessionId: string }> }) { + const { sessionId } = await params + const userSession = await requireAuth() + if (!getEditorSessionManager().isViewerOf(userSession.user.id, sessionId)) forbidden() + return new Response(null, { status: 200 }) +} diff --git a/src/lib/server/editorSessions.ts b/src/lib/server/editorSessions.ts index 0ba6d82..9d24c09 100644 --- a/src/lib/server/editorSessions.ts +++ b/src/lib/server/editorSessions.ts @@ -129,6 +129,15 @@ export class EditorSessionManager { void session.dispose() } + /** Returns true iff `userId` is the viewer of the session `sessionId`. */ + isViewerOf(userId: string, sessionId: string): boolean { + for (const servers of this.vscServers.values()) { + const s = servers.find(s => s.uuid === sessionId) + if (s) return s.viewer.id === userId + } + return false + } + async listSessions(): Promise { const result: EditorSessionInfo[] = [] for (const [projectId, servers] of this.vscServers) { diff --git a/src/lib/server/util.ts b/src/lib/server/util.ts index 92d928e..cb71c48 100644 --- a/src/lib/server/util.ts +++ b/src/lib/server/util.ts @@ -49,6 +49,7 @@ export const BWRAP_ARGS = '--unshare-cgroup', // TODO(security): unshare-net but allow outgoing inet connections for VSC bwraps. // https://github.com/containers/bubblewrap/issues/504 + // https://github.com/rootless-containers/slirp4netns '--die-with-parent', '--new-session', '--clearenv', diff --git a/src/lib/server/vscodeServer.ts b/src/lib/server/vscodeServer.ts index f268560..c2d0965 100644 --- a/src/lib/server/vscodeServer.ts +++ b/src/lib/server/vscodeServer.ts @@ -57,7 +57,9 @@ async function waitForNginxRoute(path: string, timeoutMs = 10_000): Promise resolve(null)) req.end() }) - if (status !== null && status === 200) return + // The route is gated by `auth_request`, + // so this unauthenticated probe should return 401 once the route is ready. + if (status !== null && status === 401) return await new Promise(r => setTimeout(r, 100)) } throw new Error(`Timeout waiting for Nginx route ${path} (last HTTP status=${status})`) @@ -102,14 +104,15 @@ export class VscodeServerHandle { private async writeNginxUserRoute() { const conf = `location ${this.vscodeIframePath} { - proxy_pass http://127.0.0.1:${this.port}; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $http_host; - proxy_buffering off; - proxy_read_timeout 86400; - proxy_hide_header X-Frame-Options; + auth_request /api/auth-vsc/${this.uuid}; + proxy_pass http://127.0.0.1:${this.port}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_buffering off; + proxy_read_timeout 86400; + proxy_hide_header X-Frame-Options; } ` await fs.writeFile(this.nginxUserRoutePath, conf)