Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/cli/src/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { registerGetCommand } from "./core/get";
import { registerLoginCommand } from "./core/login";
import { registerRemoveCommand } from "./core/remove";
import { registerSignupCommand } from "./core/signup";
import { registerStatusCommand } from "./core/status";
import { registerStudioCommand } from "./core/studio";
import { registerUpdateCommand } from "./core/update";

Expand Down Expand Up @@ -121,7 +120,6 @@ export function registerCoreCommands(program: DirectorCommand): void {
});

registerConfigCommand(program);
registerStatusCommand(program);

registerDebugCommands(program);
}
9 changes: 2 additions & 7 deletions apps/cli/src/commands/core/get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { GatewayRouterOutputs } from "@director.run/gateway/client";
import { getSSEPathForPlaybook } from "@director.run/gateway/helpers";
import { getStreamablePathForPlaybook } from "@director.run/gateway/helpers";
import {
blue,
green,
Expand Down Expand Up @@ -111,11 +109,8 @@ export function printPlaybookDetails(
id,
name,
description: description ?? "--",
streamableURL: joinURL(
env.GATEWAY_URL,
getStreamablePathForPlaybook(playbook.id),
),
sseURL: joinURL(env.GATEWAY_URL, getSSEPathForPlaybook(playbook.id)),
streamableURL: joinURL(env.GATEWAY_URL, playbook.paths.streamable),
sseURL: joinURL(env.GATEWAY_URL, playbook.paths.sse),
}),
);

Expand Down
16 changes: 0 additions & 16 deletions apps/cli/src/commands/core/status.ts

This file was deleted.

1 change: 0 additions & 1 deletion apps/cli/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ describe("CLI integration tests", () => {

gateway = await Gateway.start({
database,
baseUrl: baseURL,
port: env.PORT,
});

Expand Down
1 change: 0 additions & 1 deletion apps/gateway/bin/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ async function start() {

await Gateway.start({
database,
baseUrl: env.BASE_URL,
port: env.PORT,
studioAssetsPath: getStudioAssetsPath(),
});
Expand Down
106 changes: 10 additions & 96 deletions apps/gateway/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { Server } from "node:http";
import { createOauthCallbackRouter } from "@director.run/mcp/oauth/oauth-callback-router";
import { isDevelopment } from "@director.run/utilities/env";
import { getLogger } from "@director.run/utilities/logger";
import {
errorRequestHandler,
notFoundHandler,
} from "@director.run/utilities/middleware/index";
import { logRequests } from "@director.run/utilities/middleware/index";
import { spaMiddleware } from "@director.run/utilities/middleware/spa";
import { Telemetry } from "@director.run/utilities/telemetry";
import { joinURL } from "@director.run/utilities/url";
import { toNodeHandler } from "better-auth/node";
import cors from "cors";
import express from "express";
Expand All @@ -18,6 +15,8 @@ import type { Database } from "./db/database";
import { env } from "./env";
import { PlaybookStore } from "./playbooks/playbook-store";
import { createMCPRouter } from "./routers/mcp/mcp";
import { createOauthClientRouter } from "./routers/oauth-client-callback";
import { createStudioRouter } from "./routers/studio";
import { createTRPCExpressMiddleware } from "./routers/trpc";

const logger = getLogger("Gateway");
Expand All @@ -28,21 +27,18 @@ export class Gateway {
public readonly database: Database;
private app: express.Express;
private studioAssetsPath?: string;
private baseUrl: string;
public readonly port: number;

private constructor(attribs: {
playbookStore: PlaybookStore;
database: Database;
telemetry?: Telemetry;
studioAssetsPath?: string;
baseUrl: string;
port: number;
}) {
this.playbookStore = attribs.playbookStore;
this.database = attribs.database;
this.studioAssetsPath = attribs.studioAssetsPath;
this.baseUrl = attribs.baseUrl;
this.port = attribs.port;

this.app = express();
Expand All @@ -53,32 +49,12 @@ export class Gateway {
credentials: true,
}),
);
this.app.use(logRequests());
if (this.studioAssetsPath) {
logger.debug({
message: "serving studio assets from",
distPath: this.studioAssetsPath,
});
this.app.use(
"/studio",
spaMiddleware({
distPath: this.studioAssetsPath,
config: {
basePath: "/studio",
gatewayUrl: this.baseUrl,
registryUrl: env.REGISTRY_URL,
},
}),
);

this.app.get("/", (_, res) => {
res.redirect("/studio");
});
} else {
logger.warn({
message: "studioAssetsPath not provided, studio will not be available",
});
}
this.app.use(logRequests());
this.app.use(
"/",
createStudioRouter({ assetsPath: this.studioAssetsPath }),
);

this.app.use(
"/playbooks",
Expand All @@ -90,61 +66,7 @@ export class Gateway {

this.app.use(
"/",
createOauthCallbackRouter({
getSession: async (req) => {
const session = await auth.api.getSession({
headers: req.headers as Record<string, string>,
});
if (!session) {
return null;
}
return { userId: session.user.id };
},
onAuthorizationSuccess: async (factoryId, providerId, code, userId) => {
await this.playbookStore.onAuthorizationSuccess(
factoryId,
providerId,
code,
userId,
);
if (this.studioAssetsPath) {
// Redirect to hosted studio callback page
return {
redirectUrl: joinURL(
this.baseUrl,
`studio/oauth/${factoryId}/${providerId}/callback`,
),
};
} else if (isDevelopment()) {
// redirect to dev studio callback page
return {
redirectUrl: `http://localhost:3000/oauth/${factoryId}/${providerId}/callback`,
};
}
},
onAuthorizationError: (factoryId, providerId, error) => {
logger.error({
error,
message: `failed to authorize ${factoryId} ${providerId}: ${error.message}`,
});
// Only expose the error message to the client, not stack traces or internal details
const safeErrorMessage = encodeURIComponent(error.message);
if (this.studioAssetsPath) {
// Redirect to hosted studio callback page
return {
redirectUrl: joinURL(
this.baseUrl,
`studio/oauth/${factoryId}/${providerId}/callback?error=${safeErrorMessage}`,
),
};
} else if (isDevelopment()) {
// redirect to dev studio callback page
return {
redirectUrl: `http://localhost:3000/oauth/${factoryId}/${providerId}/callback?error=${safeErrorMessage}`,
};
}
},
}),
createOauthClientRouter({ playbookStore: this.playbookStore }),
);

// SECURITY: Force consent screen for MCP OAuth authorize requests
Expand Down Expand Up @@ -203,13 +125,7 @@ export class Gateway {
database: this.database,
}),
);
this.app.get("/", (_, res, next) => {
if (this.studioAssetsPath) {
res.redirect("/studio");
} else {
return next();
}
});

this.app.all("*", notFoundHandler);
this.app.use(errorRequestHandler);
}
Expand All @@ -219,7 +135,6 @@ export class Gateway {
studioAssetsPath?: string;
database: Database;
telemetry?: Telemetry;
baseUrl: string;
port: number;
},
successCallback?: () => void,
Expand All @@ -228,7 +143,7 @@ export class Gateway {
const playbookStore = await PlaybookStore.create({
database: attribs.database,
telemetry: attribs.telemetry,
baseCallbackUrl: attribs.baseUrl,
baseCallbackUrl: env.BASE_URL,
});

attribs.telemetry?.trackEvent("gateway_start");
Expand All @@ -238,7 +153,6 @@ export class Gateway {
playbookStore,
telemetry: attribs.telemetry,
studioAssetsPath: attribs.studioAssetsPath,
baseUrl: attribs.baseUrl,
port: attribs.port,
});

Expand Down
7 changes: 0 additions & 7 deletions apps/gateway/src/helpers.ts

This file was deleted.

16 changes: 10 additions & 6 deletions apps/gateway/src/playbooks/playbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ import {
PromptManager,
} from "../capabilities/prompt-manager";
import type { Database } from "../db/database";
import {
getSSEPathForPlaybook,
getStreamablePathForPlaybook,
} from "../helpers";
import {
type PlaybookHTTPTarget,
PlaybookHTTPTargetSchema,
Expand Down Expand Up @@ -94,6 +90,14 @@ export class Playbook extends ProxyServer {
return this._userId;
}

get streamablePath() {
return `/playbooks/${this.id}/mcp`;
}

get ssePath() {
return `/playbooks/${this.id}/sse`;
}

public async addTarget(
server: PlaybookTarget | ProxyTarget,
params: { throwOnError: boolean } = { throwOnError: true },
Expand Down Expand Up @@ -328,8 +332,8 @@ export class Playbook extends ProxyServer {
),
),
paths: {
streamable: getStreamablePathForPlaybook(this.id),
sse: getSSEPathForPlaybook(this.id),
streamable: this.streamablePath,
sse: this.ssePath,
},
};
}
Expand Down
51 changes: 51 additions & 0 deletions apps/gateway/src/routers/oauth-client-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createOauthCallbackRouter } from "@director.run/mcp/oauth/oauth-callback-router";
import { getLogger } from "@director.run/utilities/logger";
import {} from "@director.run/utilities/middleware/index";
import { joinURL } from "@director.run/utilities/url";
import { auth } from "../auth";
import { env } from "../env";
import { PlaybookStore } from "../playbooks/playbook-store";

const logger = getLogger("OauthClientRouter");

export const createOauthClientRouter = ({
playbookStore,
}: { playbookStore: PlaybookStore }) =>
createOauthCallbackRouter({
getSession: async (req) => {
const session = await auth.api.getSession({
headers: req.headers as Record<string, string>,
});
if (!session) {
return null;
}
return { userId: session.user.id };
},
onAuthorizationSuccess: async (factoryId, providerId, code, userId) => {
await playbookStore.onAuthorizationSuccess(
factoryId,
providerId,
code,
userId,
);
return {
redirectUrl: joinURL(
env.BASE_URL,
`studio/oauth/${factoryId}/${providerId}/callback`,
),
};
},
onAuthorizationError: (factoryId, providerId, error) => {
logger.error({
error,
message: `failed to authorize ${factoryId} ${providerId}: ${error.message}`,
});

return {
redirectUrl: joinURL(
env.BASE_URL,
`studio/oauth/${factoryId}/${providerId}/callback?error=${encodeURIComponent(error.message)}`,
),
};
},
});
42 changes: 42 additions & 0 deletions apps/gateway/src/routers/studio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { isDevelopment } from "@director.run/utilities/env";
import { getLogger } from "@director.run/utilities/logger";
import { spaMiddleware } from "@director.run/utilities/middleware/spa";
import express from "express";
import { env } from "../env";

const logger = getLogger("studio_router");

export function createStudioRouter({
assetsPath,
}: {
assetsPath?: string;
}): express.Router {
const router = express.Router();
if (assetsPath) {
logger.debug({
message: "serving studio assets from",
distPath: assetsPath,
});
router.use(
"/studio",
spaMiddleware({
distPath: assetsPath,
config: {
basePath: "/studio",
gatewayUrl: env.BASE_URL,
registryUrl: env.REGISTRY_URL,
},
}),
);

router.get("/", (_, res) => {
res.redirect("/studio");
});
} else if (isDevelopment()) {
router.use("/studio", (req, res) => {
res.redirect(`http://localhost:3000${req.originalUrl}`);

Check warning

Code scanning / CodeQL

Server-side URL redirect Medium

Untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI 6 months ago

The best way to fix this problem is to ensure that only local, trusted paths are appended to the redirect URL, and block or normalize any user input that could result in an external or unexpected redirect. Since the code is proxying requests to a local development server at localhost:3000, simply redirecting to the same path without allowing the user to control the host or scheme is ideal. We can ensure that req.originalUrl is always a path under /studio, and avoid redirecting to user-injected absolute URLs.

To implement this, parse and normalize req.originalUrl to extract only the path after /studio, and use it to construct the redirect. Alternatively, reject any requests where req.originalUrl contains suspicious elements (//, http, hosts, etc.), or always redirect to a canonical /studio path with a clean suffix.
Changes needed in apps/gateway/src/routers/studio.ts:

  • On line 37, parse req.originalUrl to only take the “rest” of the path after /studio, ensuring it does not contain any dangerous characters (like // or leading protocols), and append it to the local redirect URL.
  • Optionally, define a helper to sanitize/validate the path.
  • No new packages are required as Node.js provides URL handling (url or the global URL), but minimal string manipulation suffices here.

Suggested changeset 1
apps/gateway/src/routers/studio.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/gateway/src/routers/studio.ts b/apps/gateway/src/routers/studio.ts
--- a/apps/gateway/src/routers/studio.ts
+++ b/apps/gateway/src/routers/studio.ts
@@ -34,7 +34,21 @@
     });
   } else if (isDevelopment()) {
     router.use("/studio", (req, res) => {
-      res.redirect(`http://localhost:3000${req.originalUrl}`);
+      // Only proxy local paths under /studio to the dev server
+      const studioPrefix = '/studio';
+      let path = req.originalUrl;
+      // Remove querystring or hash if present (let's keep them in redirect, but only after sanitizing path)
+      // Ensure the path always starts with /studio
+      if (!path.startsWith(studioPrefix)) {
+        return res.redirect("http://localhost:3000/studio");
+      }
+      // Ensure no double-slash or attempts to escape (e.g. /studio//evil, or /studio/../../etc/passwd)
+      const restPath = path.slice(studioPrefix.length);
+      if (restPath.includes('//') || restPath.includes('\\') || restPath.includes('..')) {
+        return res.redirect("http://localhost:3000/studio");
+      }
+      // Safe to redirect to local dev server with valid studio path
+      res.redirect(`http://localhost:3000${studioPrefix}${restPath}`);
     });
   }
 
EOF
@@ -34,7 +34,21 @@
});
} else if (isDevelopment()) {
router.use("/studio", (req, res) => {
res.redirect(`http://localhost:3000${req.originalUrl}`);
// Only proxy local paths under /studio to the dev server
const studioPrefix = '/studio';
let path = req.originalUrl;
// Remove querystring or hash if present (let's keep them in redirect, but only after sanitizing path)
// Ensure the path always starts with /studio
if (!path.startsWith(studioPrefix)) {
return res.redirect("http://localhost:3000/studio");
}
// Ensure no double-slash or attempts to escape (e.g. /studio//evil, or /studio/../../etc/passwd)
const restPath = path.slice(studioPrefix.length);
if (restPath.includes('//') || restPath.includes('\\') || restPath.includes('..')) {
return res.redirect("http://localhost:3000/studio");
}
// Safe to redirect to local dev server with valid studio path
res.redirect(`http://localhost:3000${studioPrefix}${restPath}`);
});
}

Copilot is powered by AI and may make mistakes. Always verify output.
});
}

return router;
}
Loading
Loading