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
3 changes: 1 addition & 2 deletions packages/console/app/src/routes/stripe/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export async function POST(input: APIEvent) {
input.request.headers.get("stripe-signature")!,
Resource.STRIPE_WEBHOOK_SECRET.value,
)
console.log(body.type, JSON.stringify(body, null, 2))
console.log("stripe webhook:", body.type, body.id)

return (async () => {
if (body.type === "customer.updated") {
Expand Down Expand Up @@ -285,7 +285,6 @@ export async function POST(input: APIEvent) {
if (!invoiceID) throw new Error("Invoice ID not found")

const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID)
console.log(JSON.stringify(paymentIntent))
const errorMessage =
typeof paymentIntent === "object" && paymentIntent !== null
? paymentIntent.last_payment_error?.message
Expand Down
6 changes: 1 addition & 5 deletions packages/enterprise/src/routes/share/[shareID].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,10 @@ export default function () {
return <NotFound />
}
console.error(error)
const details = error instanceof Error ? (error.stack ?? error.message) : String(error)
return (
<div class="min-h-screen w-full bg-background-base text-text-base flex flex-col items-center justify-center gap-4 p-6 text-center">
<p class="text-16-medium">Unable to render this share.</p>
<p class="text-14-regular text-text-weaker">Check the console for more details.</p>
<pre class="text-12-mono text-left whitespace-pre-wrap break-words w-full max-w-200 bg-background-stronger rounded-md p-4">
{details}
</pre>
<p class="text-14-regular text-text-weaker">An unexpected error occurred. Please try again later.</p>
</div>
)
}}
Expand Down
52 changes: 48 additions & 4 deletions packages/function/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export class SyncServer extends DurableObject<Env> {
super(ctx, env)
}
async fetch() {
const secret = await this.getSecret()
if (!secret) {
return new Response("Not found", { status: 404 })
}
console.log("SyncServer subscribe")

const webSocketPair = new WebSocketPair()
Expand Down Expand Up @@ -77,6 +81,8 @@ export class SyncServer extends DurableObject<Env> {
}

public async getData() {
const secret = await this.getSecret()
if (!secret) return []
const data = (await this.ctx.storage.list()) as Map<string, any>
return Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
Expand Down Expand Up @@ -116,6 +122,10 @@ export class SyncServer extends DurableObject<Env> {
export default new Hono<{ Bindings: Env }>()
.get("/", (c) => c.text("Hello, world!"))
.post("/share_create", async (c) => {
const authHeader = c.req.header("authorization")
if (!authHeader || authHeader !== `Bearer ${Resource.ADMIN_SECRET.value}`) {
return c.text("Unauthorized", 401)
}
const body = await c.req.json<{ sessionID: string }>()
const sessionID = body.sessionID
const short = SyncServer.shortName(sessionID)
Expand Down Expand Up @@ -202,7 +212,9 @@ export default new Hono<{ Bindings: Env }>()
return c.json({ info, messages })
})
.post("/feishu", async (c) => {
const body = (await c.req.json()) as {
const rawBody = await c.req.text()

let body: {
challenge?: string
event?: {
message?: {
Expand All @@ -214,9 +226,41 @@ export default new Hono<{ Bindings: Env }>()
}
}
}
console.log(JSON.stringify(body, null, 2))
const challenge = body.challenge
if (challenge) return c.json({ challenge })
try {
body = JSON.parse(rawBody)
} catch {
return c.text("Invalid JSON body", 400)
}

// Challenge requests during setup don't require signature verification
if (body.challenge) return c.json({ challenge: body.challenge })

// All non-challenge requests must have a valid signature
const signature = c.req.header("x-lark-signature")
const timestamp = c.req.header("x-lark-request-timestamp")
const nonce = c.req.header("x-lark-request-nonce")
if (!signature || !timestamp || !nonce) {
return c.text("Missing signature headers", 403)
}

// Reject stale timestamps (±5 min window)
const ts = parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (isNaN(ts) || Math.abs(now - ts) > 300) {
return c.text("Timestamp expired", 403)
}

const encryptKey = Resource.FEISHU_APP_SECRET.value
const payload = timestamp + nonce + encryptKey + rawBody
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload))
const expected = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
if (expected !== signature) {
return c.text("Invalid signature", 403)
}

console.log("feishu webhook:", body.event?.message?.message_id ?? "unknown")

const content = body.event?.message?.content
const parsed =
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
import { Log } from "../util"
import { NamedError } from "@mimo-ai/shared/util/error"
import z from "zod/v4"
import { Installation } from "../installation"

Check warning on line 17 in packages/opencode/src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Identifier 'Installation' is imported but never used.
import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@mimo-ai/shared/filesystem"
import { assertSafeUrl } from "@/util/ssrf"
import { McpOAuthProvider } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
Expand Down Expand Up @@ -287,6 +288,11 @@
key: string,
mcp: ConfigMCP.Info & { type: "remote" },
) {
yield* Effect.tryPromise({
try: () => assertSafeUrl(mcp.url),
catch: (e) => new Error(e instanceof Error ? e.message : String(e)),
}).pipe(Effect.orDie)

const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined
Expand Down Expand Up @@ -745,6 +751,11 @@
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)

yield* Effect.tryPromise({
try: () => assertSafeUrl(mcpConfig.url),
catch: (e) => new Error(e instanceof Error ? e.message : String(e)),
}).pipe(Effect.orDie)

// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined

Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const log = Log.create({ service: "mcp.oauth-callback" })
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH

function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;")
}

const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
Expand Down Expand Up @@ -45,7 +49,7 @@ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ const HTML_SUCCESS = `<!doctype html>
</body>
</html>`

function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;")
}

const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
Expand Down Expand Up @@ -229,7 +233,7 @@ const HTML_ERROR = (error: string) => `<!doctype html>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
Expand Down
4 changes: 1 addition & 3 deletions packages/opencode/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const ErrorMiddleware: ErrorHandler = (err, c) => {
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 409 })
}
if (err instanceof HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
const message = err instanceof Error ? err.message : "Internal Server Error"
return c.json(new NamedError.Unknown({ message }).toObject(), {
status: 500,
})
Expand All @@ -48,8 +48,6 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {

const username = Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"

if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)

return basicAuth({ username, password })(c, next)
}

Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/src/server/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { MiddlewareHandler } from "hono"

const windows = new Map<string, { count: number; resetAt: number }>()

let lastSweep = Date.now()
const SWEEP_INTERVAL = 60_000

function sweep() {
const now = Date.now()
if (now - lastSweep < SWEEP_INTERVAL) return
lastSweep = now
for (const [key, entry] of windows) {
if (now >= entry.resetAt) windows.delete(key)
}
}

export function RateLimitMiddleware(opts: {
windowMs: number
max: number
keyPrefix?: string
}): MiddlewareHandler {
return async (c, next) => {
sweep()
const key = (opts.keyPrefix ?? c.req.path) + ":" + (c.req.header("x-forwarded-for") ?? "local")
const now = Date.now()
let entry = windows.get(key)
if (!entry || now >= entry.resetAt) {
entry = { count: 0, resetAt: now + opts.windowMs }
windows.set(key, entry)
}
entry.count++
if (entry.count > opts.max) {
c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)))
return c.json({ error: "Too many requests" }, 429)
}
return next()
}
}
8 changes: 6 additions & 2 deletions packages/opencode/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider"

Check warning on line 27 in packages/opencode/src/server/routes/instance/session.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Identifier 'Provider' is imported but never used.
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Bus } from "@/bus"
import { NamedError } from "@mimo-ai/shared/util/error"
import { jsonRequest, runRequest } from "./trace"
import { RateLimitMiddleware } from "../../rate-limit"

const log = Log.create({ service: "server" })

Expand Down Expand Up @@ -698,8 +699,9 @@
.number()
.int()
.min(0)
.max(1000)
.optional()
.meta({ description: "Maximum number of messages to return" }),
.meta({ description: "Maximum number of messages to return (max 1000)" }),
before: z
.string()
.optional()
Expand Down Expand Up @@ -744,7 +746,7 @@
Effect.gen(function* () {
const session = yield* Session.Service
yield* session.get(sessionID)
return yield* session.messages({ sessionID, agentID })
return yield* session.messages({ sessionID, agentID, limit: 1000 })
}),
)
return c.json(messages)
Expand Down Expand Up @@ -1008,6 +1010,7 @@
)
.post(
"/:sessionID/prompt_async",
RateLimitMiddleware({ windowMs: 60_000, max: 20, keyPrefix: "prompt_async" }),
describeRoute({
summary: "Send async message",
description:
Expand Down Expand Up @@ -1122,6 +1125,7 @@
)
.post(
"/:sessionID/shell",
RateLimitMiddleware({ windowMs: 60_000, max: 20, keyPrefix: "shell" }),
describeRoute({
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,17 @@ export async function listen(opts: {
mdns?: boolean
mdnsDomain?: string
cors?: string[]
noAuth?: boolean
}): Promise<Listener> {
const isLoopback =
opts.hostname === "127.0.0.1" || opts.hostname === "localhost" || opts.hostname === "::1"
if (!isLoopback && !Flag.MIMOCODE_SERVER_PASSWORD && !opts.noAuth) {
throw new Error(
"Refusing to bind to non-loopback address without MIMOCODE_SERVER_PASSWORD. " +
"Set the environment variable or pass noAuth to explicitly allow unauthenticated access.",
)
}

const built = create(opts)
const server = await built.runtime.listen(opts)

Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/tool/webfetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Tool from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { isImageAttachment } from "@/util/media"
import { assertSafeUrl } from "@/util/ssrf"

const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
Expand Down Expand Up @@ -34,6 +35,8 @@ export const WebFetchTool = Tool.define(
throw new Error("URL must start with http:// or https://")
}

yield* Effect.promise(() => assertSafeUrl(params.url))

yield* ctx.ask({
permission: "webfetch",
patterns: [params.url],
Expand Down Expand Up @@ -90,6 +93,12 @@ export const WebFetchTool = Tool.define(
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
)

// Block SSRF via redirect: if the response was redirected, validate final URL
const source = (response as any).source as Response | undefined
if (source?.url && source.url !== params.url) {
yield* Effect.promise(() => assertSafeUrl(source.url))
}

// Check content length
const contentLength = response.headers["content-length"]
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
Expand Down
Loading
Loading