diff --git a/packages/core/src/sentry.ts b/packages/core/src/sentry.ts index f642701c3..5d192fde1 100644 --- a/packages/core/src/sentry.ts +++ b/packages/core/src/sentry.ts @@ -29,7 +29,9 @@ export async function initSentry() { Sentry.init({ dsn: sentryDsn, - sendDefaultPii: true, + // Do not ship IP/cookies/headers by default — user content and identifiers + // travel through this stack and Sentry has no scrubbing for our schema. + sendDefaultPii: false, profileSessionSampleRate: 1.0, tracesSampleRate: 1.0, // Capture 100% of traces for better visibility integrations: [ diff --git a/packages/gateway/src/__tests__/agent-ownership.test.ts b/packages/gateway/src/__tests__/agent-ownership.test.ts new file mode 100644 index 000000000..437b14941 --- /dev/null +++ b/packages/gateway/src/__tests__/agent-ownership.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import type { SettingsTokenPayload } from "../auth/settings/token-service"; +import { verifyOwnedAgentAccess } from "../routes/shared/agent-ownership"; + +const makeSession = ( + overrides: Partial = {} +): SettingsTokenPayload => ({ + userId: "user-owner", + oauthUserId: "user-owner", + platform: "telegram", + exp: Date.now() + 60_000, + ...overrides, +}); + +const stubUserAgentsStore = (owner: { + platform: string; + userId: string; + agentId: string; +}) => ({ + async ownsAgent(platform: string, userId: string, agentId: string) { + return ( + platform === owner.platform && + userId === owner.userId && + agentId === owner.agentId + ); + }, + async addAgent() { + // no-op: only used for best-effort reconciliation in verifyOwnedAgentAccess + }, +}); + +const stubAgentMetadataStore = ( + owner: { platform: string; userId: string } | null +) => ({ + async getMetadata(_agentId: string) { + return owner + ? { owner: { platform: owner.platform, userId: owner.userId } } + : null; + }, +}); + +describe("verifyOwnedAgentAccess (cross-tenant ownership)", () => { + test("owner sees their own agent", async () => { + const session = makeSession(); + const result = await verifyOwnedAgentAccess(session, "agent-1", { + userAgentsStore: stubUserAgentsStore({ + platform: "telegram", + userId: "user-owner", + agentId: "agent-1", + }) as any, + agentMetadataStore: stubAgentMetadataStore({ + platform: "telegram", + userId: "user-owner", + }) as any, + }); + expect(result.authorized).toBe(true); + }); + + test("cross-tenant user on same platform is rejected", async () => { + const session = makeSession({ + userId: "user-attacker", + oauthUserId: "user-attacker", + }); + const result = await verifyOwnedAgentAccess(session, "agent-1", { + userAgentsStore: stubUserAgentsStore({ + platform: "telegram", + userId: "user-owner", + agentId: "agent-1", + }) as any, + agentMetadataStore: stubAgentMetadataStore({ + platform: "telegram", + userId: "user-owner", + }) as any, + }); + expect(result.authorized).toBe(false); + }); + + test("cross-platform user with the same userId is rejected", async () => { + const session = makeSession({ platform: "slack" }); + const result = await verifyOwnedAgentAccess(session, "agent-1", { + userAgentsStore: stubUserAgentsStore({ + platform: "telegram", + userId: "user-owner", + agentId: "agent-1", + }) as any, + agentMetadataStore: stubAgentMetadataStore({ + platform: "telegram", + userId: "user-owner", + }) as any, + }); + expect(result.authorized).toBe(false); + }); + + test("agent-scoped session cannot access a different agent", async () => { + const session = makeSession({ agentId: "agent-1" }); + const result = await verifyOwnedAgentAccess(session, "agent-2", { + userAgentsStore: stubUserAgentsStore({ + platform: "telegram", + userId: "user-owner", + agentId: "agent-2", + }) as any, + agentMetadataStore: stubAgentMetadataStore({ + platform: "telegram", + userId: "user-owner", + }) as any, + }); + expect(result.authorized).toBe(false); + }); + + test("admin session bypasses ownership", async () => { + const session = makeSession({ isAdmin: true, userId: "any" }); + const result = await verifyOwnedAgentAccess(session, "agent-1", { + userAgentsStore: stubUserAgentsStore({ + platform: "telegram", + userId: "user-owner", + agentId: "agent-1", + }) as any, + agentMetadataStore: stubAgentMetadataStore(null) as any, + }); + expect(result.authorized).toBe(true); + }); + + test("unknown agent (no metadata) is rejected for non-admin", async () => { + const session = makeSession({ + userId: "user-attacker", + oauthUserId: "user-attacker", + }); + const result = await verifyOwnedAgentAccess(session, "agent-unknown", { + userAgentsStore: stubUserAgentsStore({ + platform: "telegram", + userId: "user-owner", + agentId: "agent-1", + }) as any, + agentMetadataStore: stubAgentMetadataStore(null) as any, + }); + expect(result.authorized).toBe(false); + }); + + test("external session with mismatched oauthUserId is rejected", async () => { + const session = makeSession({ + platform: "external", + userId: "u1", + oauthUserId: "attacker-oauth", + }); + const result = await verifyOwnedAgentAccess(session, "agent-1", { + userAgentsStore: stubUserAgentsStore({ + platform: "external", + userId: "owner-oauth", + agentId: "agent-1", + }) as any, + agentMetadataStore: stubAgentMetadataStore({ + platform: "telegram", + userId: "owner-oauth", + }) as any, + }); + expect(result.authorized).toBe(false); + }); +}); diff --git a/packages/gateway/src/connections/slack-instruction-provider.ts b/packages/gateway/src/connections/slack-instruction-provider.ts index 7469199ab..2b2ab30db 100644 --- a/packages/gateway/src/connections/slack-instruction-provider.ts +++ b/packages/gateway/src/connections/slack-instruction-provider.ts @@ -1,13 +1,18 @@ -import type { InstructionContext, InstructionProvider } from "@lobu/core"; +import type { InstructionContext } from "@lobu/core"; +import { BaseInstructionProvider } from "../services/instruction-service"; import type { ChatInstanceManager } from "./chat-instance-manager"; -export class SlackInstructionProvider implements InstructionProvider { - name = "slack-identity"; - priority = 20; +export class SlackInstructionProvider extends BaseInstructionProvider { + readonly name = "slack-identity"; + readonly priority = 20; - constructor(private readonly manager: ChatInstanceManager) {} + constructor(private readonly manager: ChatInstanceManager) { + super(); + } - async getInstructions(context: InstructionContext): Promise { + protected async buildInstructions( + context: InstructionContext + ): Promise { const connections = await this.manager.listConnections({ platform: "slack", templateAgentId: context.agentId, diff --git a/packages/gateway/src/services/instruction-service.ts b/packages/gateway/src/services/instruction-service.ts index cffc2dab8..2cbd95861 100644 --- a/packages/gateway/src/services/instruction-service.ts +++ b/packages/gateway/src/services/instruction-service.ts @@ -35,7 +35,7 @@ interface SessionContextData { * assembly. This removes the identical boilerplate each subclass used to * declare. */ -abstract class BaseInstructionProvider implements InstructionProvider { +export abstract class BaseInstructionProvider implements InstructionProvider { abstract readonly name: string; abstract readonly priority: number; diff --git a/packages/owletto-backend/src/__tests__/unit/csp.test.ts b/packages/owletto-backend/src/__tests__/unit/csp.test.ts new file mode 100644 index 000000000..51d68561d --- /dev/null +++ b/packages/owletto-backend/src/__tests__/unit/csp.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import { isValidFrameAncestor } from '../../utils/csp'; + +describe('isValidFrameAncestor', () => { + it('accepts well-formed host-sources', () => { + expect(isValidFrameAncestor('https://lobu.ai')).toBe(true); + expect(isValidFrameAncestor('https://*.lobu.ai')).toBe(true); + expect(isValidFrameAncestor('https://app.lobu.ai:8080')).toBe(true); + expect(isValidFrameAncestor('http://localhost:3000')).toBe(true); + }); + + it('accepts scheme-only sources', () => { + expect(isValidFrameAncestor('https:')).toBe(true); + expect(isValidFrameAncestor('wss:')).toBe(true); + }); + + it('rejects entries with embedded whitespace', () => { + expect(isValidFrameAncestor('https:// lobu.ai')).toBe(false); + expect(isValidFrameAncestor('https://lobu .ai')).toBe(false); + expect(isValidFrameAncestor(' https://lobu.ai')).toBe(false); + }); + + it('rejects entries with paths or queries', () => { + expect(isValidFrameAncestor('https://lobu.ai/embed')).toBe(false); + expect(isValidFrameAncestor('https://lobu.ai?x=1')).toBe(false); + expect(isValidFrameAncestor('https://lobu.ai#f')).toBe(false); + }); + + it('rejects malformed or suspicious entries', () => { + expect(isValidFrameAncestor('')).toBe(false); + expect(isValidFrameAncestor('lobu.ai')).toBe(false); // no scheme + expect(isValidFrameAncestor("'self'")).toBe(false); // keyword already added separately + expect(isValidFrameAncestor('javascript:alert(1)')).toBe(false); + expect(isValidFrameAncestor('https://')).toBe(false); + expect(isValidFrameAncestor('https://lobu.ai attacker.com')).toBe(false); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/unit/resolve-path-redaction.test.ts b/packages/owletto-backend/src/__tests__/unit/resolve-path-redaction.test.ts new file mode 100644 index 000000000..638bd3217 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/unit/resolve-path-redaction.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { stripMemberEmailsFromRows } from '../../utils/member-redaction'; + +describe('stripMemberEmailsFromRows ($member traversal redaction)', () => { + it('strips emailField from $member rows surfaced via template data', () => { + const input = { + members: [ + { + entity_type: '$member', + name: 'Alice', + metadata: { email: 'alice@example.com', role: 'member' }, + }, + { + entity_type: '$member', + name: 'Bob', + metadata: { email: 'bob@example.com', role: 'admin' }, + }, + ], + }; + const out = stripMemberEmailsFromRows(input, 'email'); + expect(out).not.toBeNull(); + const rows = out!.members as Array<{ metadata: Record }>; + expect(rows[0]!.metadata).toEqual({ role: 'member' }); + expect(rows[1]!.metadata).toEqual({ role: 'admin' }); + }); + + it('also strips top-level email columns (e.g. SELECT metadata->> email AS email)', () => { + const input = { + members: [ + { + entity_type: '$member', + name: 'Alice', + email: 'alice@example.com', + }, + ], + }; + const out = stripMemberEmailsFromRows(input, 'email'); + const rows = out!.members as Array>; + expect('email' in rows[0]!).toBe(false); + expect(rows[0]!.name).toBe('Alice'); + }); + + it('leaves non-member rows untouched', () => { + const input = { + companies: [ + { + entity_type: 'company', + name: 'Acme', + metadata: { email: 'hello@acme.example', website: 'acme.example' }, + }, + ], + }; + const out = stripMemberEmailsFromRows(input, 'email'); + expect(out!.companies).toEqual(input.companies); + }); + + it('passes through nullish or malformed rows', () => { + expect(stripMemberEmailsFromRows(null, 'email')).toBeNull(); + const out = stripMemberEmailsFromRows( + { mixed: [null, 'string', 42, { entity_type: '$member', metadata: {} }] }, + 'email' + ); + expect(out!.mixed).toEqual([ + null, + 'string', + 42, + { entity_type: '$member', metadata: {} }, + ]); + }); + + it('no-ops when emailField is empty', () => { + const input = { + members: [ + { entity_type: '$member', metadata: { email: 'a@b' } }, + ], + }; + const out = stripMemberEmailsFromRows(input, ''); + expect(out).toEqual(input); + }); +}); diff --git a/packages/owletto-backend/src/auth/index.tsx b/packages/owletto-backend/src/auth/index.tsx index c565b7011..c6702584c 100644 --- a/packages/owletto-backend/src/auth/index.tsx +++ b/packages/owletto-backend/src/auth/index.tsx @@ -14,7 +14,7 @@ import { updateMemberEntityAccess, updateMemberEntityStatus, } from '../utils/member-entity'; -import { getConfiguredPublicOrigin } from '../utils/public-origin'; +import { getConfiguredPublicOrigin, normalizeHost } from '../utils/public-origin'; import { TtlCache } from '../utils/ttl-cache'; import { resolveBaseUrl, safeParseUrl } from './base-url'; import { @@ -133,13 +133,13 @@ export async function createAuth(env: Env, request?: Request) { // When AUTH_COOKIE_DOMAIN is set (e.g. ".lobu.ai"), trust all subdomains so // session cookies travel across {org}.lobu.ai → lobu.ai cross-origin requests. - const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim(); - if (cookieDomain) { - const normalized = cookieDomain.startsWith('.') ? cookieDomain.slice(1) : cookieDomain; - if (normalized) { - trustedOriginSet.add(`https://*.${normalized}`); - trustedOriginSet.add(`https://${normalized}`); - } + // Normalize via normalizeHost so IDN/uppercase/trailing-dot variants of the + // env value cannot silently mismatch the ASCII-lowercased origin BetterAuth + // sees from the browser. + const normalizedCookieZone = normalizeHost(process.env.AUTH_COOKIE_DOMAIN); + if (normalizedCookieZone) { + trustedOriginSet.add(`https://*.${normalizedCookieZone}`); + trustedOriginSet.add(`https://${normalizedCookieZone}`); } const auth = betterAuth({ diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index d2a776148..1302c5221 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -58,6 +58,7 @@ import { restUpdateContentClassification, } from './rest-api'; import { entityLinkMatchSql } from './utils/content-search'; +import { isValidFrameAncestor } from './utils/csp'; import { errorMessage } from './utils/errors'; import logger from './utils/logger'; import { generateOpenAPISpec } from './utils/openapi-generator'; @@ -298,7 +299,7 @@ app.use('/*', async (c, next) => { ? rawFrameAncestors .split(/[\s,]+/) .map((entry) => entry.trim()) - .filter(Boolean) + .filter((entry) => isValidFrameAncestor(entry)) .join(' ') : 'https://lobu.ai https://*.lobu.ai'; c.header( diff --git a/packages/owletto-backend/src/tools/organizations.ts b/packages/owletto-backend/src/tools/organizations.ts index 15162c8a7..0c13d9b6e 100644 --- a/packages/owletto-backend/src/tools/organizations.ts +++ b/packages/owletto-backend/src/tools/organizations.ts @@ -8,6 +8,7 @@ import { type Static, Type } from '@sinclair/typebox'; import { getDb } from '../db/client'; import type { Env } from '../index'; +import { getRateLimiter, RateLimitPresets } from '../utils/rate-limiter'; import { buildWorkspaceInstructions } from '../utils/workspace-instructions'; import { getWorkspaceProvider } from '../workspace'; import { joinPublicOrganization } from '../workspace/join-public'; @@ -118,6 +119,19 @@ export async function joinOrganization( org: { slug: string; name: string; id: string; role: string }; note?: string; }> { + // Match the REST endpoint's 10/hour cap (keyed on userId here since MCP tool + // calls don't carry a client IP). + const rateLimit = getRateLimiter().checkLimit( + `rate:join-public-org:user:${ctx.userId}`, + RateLimitPresets.JOIN_PUBLIC_ORG_PER_IP_HOUR + ); + if (!rateLimit.allowed) { + throw new Error( + rateLimit.errorMessage ?? + 'Join rate limit exceeded. Maximum 10 join attempts per hour.' + ); + } + let slug = args.organization_slug ?? null; if (!slug) { if (!ctx.currentOrgId) { diff --git a/packages/owletto-backend/src/tools/resolve_path.ts b/packages/owletto-backend/src/tools/resolve_path.ts index 378210ffd..81e2b5544 100644 --- a/packages/owletto-backend/src/tools/resolve_path.ts +++ b/packages/owletto-backend/src/tools/resolve_path.ts @@ -18,6 +18,7 @@ import { executeDataSources, } from '../utils/execute-data-sources'; import { resolveMemberSchemaFieldsFromSchema } from '../utils/member-entity-type'; +import { stripMemberEmailsFromRows } from '../utils/member-redaction'; import { RESERVED_PATHS } from '../utils/reserved'; import { getWorkspaceProvider } from '../workspace'; import type { ToolContext } from './registry'; @@ -456,7 +457,8 @@ async function _resolvePath( ]) ); const mergedTabs = mergeTabs(entityTabs, entityTypeTabs); - const processedEntityTabs = await processTabsDataSources(mergedTabs, entityDataCtx, sql); + let processedEntityTabs = await processTabsDataSources(mergedTabs, entityDataCtx, sql); + let redactedTemplateData = entityTemplateData; if (entityRow.entity_type === '$member' && !ctx.memberRole) { throw new Error( 'Member details are only visible to members of this workspace. Join the workspace to see members.' @@ -465,7 +467,7 @@ async function _resolvePath( const rawEntityMetadata = entityRow.metadata ?? {}; let safeEntityMetadata = rawEntityMetadata; const canSeeEmail = ctx.memberRole === 'owner' || ctx.memberRole === 'admin'; - if (entityRow.entity_type === '$member' && !canSeeEmail) { + if (!canSeeEmail) { const schemaRow = await simpleQuery(sql` SELECT metadata_schema FROM entity_types WHERE slug = '$member' AND organization_id = ${workspace.id} AND deleted_at IS NULL @@ -473,10 +475,19 @@ async function _resolvePath( `); const memberSchema = (schemaRow[0]?.metadata_schema as Record | null) ?? null; const { emailField } = resolveMemberSchemaFieldsFromSchema(memberSchema); - if (emailField in safeEntityMetadata) { + if (entityRow.entity_type === '$member' && emailField in safeEntityMetadata) { const { [emailField]: _drop, ...rest } = safeEntityMetadata; safeEntityMetadata = rest; } + // Also strip member emails that surface via template data sources or tabs + // (e.g. a dashboard tab that lists members). Without this, a data-source + // query like `SELECT * FROM entities WHERE entity_type='$member'` would + // leak emails even when the single-entity redaction above is not tripped. + redactedTemplateData = stripMemberEmailsFromRows(entityTemplateData, emailField); + processedEntityTabs = processedEntityTabs.map((tab) => ({ + ...tab, + template_data: stripMemberEmailsFromRows(tab.template_data, emailField), + })); } resolvedEntity = { id: entityRow.id, @@ -487,7 +498,7 @@ async function _resolvePath( metadata: safeEntityMetadata, json_template: entityCleanTpl, json_template_version: toVersionNumber(entityRow.json_template_version), - template_data: entityTemplateData, + template_data: redactedTemplateData, tabs: processedEntityTabs, created_at: createdAt, total_content: Number(eventsCount?.cnt) || 0, diff --git a/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts b/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts index 9a7a6d044..d97012765 100644 --- a/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts @@ -3,6 +3,7 @@ import { extractSubdomainOrg, getCanonicalRedirectUrl, getSubdomainZone, + normalizeHost, } from '../public-origin'; const RESERVED = new Set(['www', 'api', 'app', 'admin', 'auth', 'mcp']); @@ -106,4 +107,36 @@ describe('extractSubdomainOrg', () => { it('is case-insensitive', () => { expect(extractSubdomainOrg('ACME.Lobu.AI', 'lobu.ai', RESERVED)).toBe('acme'); }); + + it('ignores a trailing dot on the host', () => { + expect(extractSubdomainOrg('acme.lobu.ai.', 'lobu.ai', RESERVED)).toBe('acme'); + }); + + it('matches IDN hosts to their ASCII zone via punycode', () => { + // "müller" → "xn--mller-kva" under IDNA. + const result = extractSubdomainOrg('müller.lobu.ai', 'lobu.ai', RESERVED); + expect(result).toBe('xn--mller-kva'); + }); + + it('tolerates a zone with leading dot or uppercase', () => { + expect(extractSubdomainOrg('acme.lobu.ai', '.LOBU.AI', RESERVED)).toBe('acme'); + }); +}); + +describe('normalizeHost', () => { + it('lowercases and strips port / leading-dot / trailing-dot', () => { + expect(normalizeHost('App.Lobu.AI:8080')).toBe('app.lobu.ai'); + expect(normalizeHost('.lobu.ai')).toBe('lobu.ai'); + expect(normalizeHost('lobu.ai.')).toBe('lobu.ai'); + }); + + it('converts IDN to punycode', () => { + expect(normalizeHost('müller.example.com')).toBe('xn--mller-kva.example.com'); + }); + + it('returns null for missing or malformed input', () => { + expect(normalizeHost(undefined)).toBeNull(); + expect(normalizeHost('')).toBeNull(); + expect(normalizeHost(' ')).toBeNull(); + }); }); diff --git a/packages/owletto-backend/src/utils/csp.ts b/packages/owletto-backend/src/utils/csp.ts new file mode 100644 index 000000000..7b67c8dd0 --- /dev/null +++ b/packages/owletto-backend/src/utils/csp.ts @@ -0,0 +1,15 @@ +/** + * CSP frame-ancestor source-expression validator. + * + * Accepts host-source (`https://example.com`, `https://*.example.com`, with + * an optional port) and scheme-source (`https:`, `wss:`). Rejects anything + * with embedded whitespace, paths, or disallowed characters, so a malformed + * env entry like `https:// lobu.ai` cannot silently weaken the policy — a + * browser receiving an unknown token in a directive treats the rest + * permissively. + */ +export function isValidFrameAncestor(entry: string): boolean { + if (!entry) return false; + if (/^[a-z][a-z0-9+\-.]*:$/i.test(entry)) return true; + return /^https?:\/\/(\*\.)?[a-z0-9.-]+(:\d+)?$/i.test(entry); +} diff --git a/packages/owletto-backend/src/utils/member-redaction.ts b/packages/owletto-backend/src/utils/member-redaction.ts new file mode 100644 index 000000000..b3a5be49c --- /dev/null +++ b/packages/owletto-backend/src/utils/member-redaction.ts @@ -0,0 +1,39 @@ +/** + * Strip the configured member email field from any `$member` rows inside a + * `Record` bag of data-source results. Applied to + * template_data and tab template_data so non-admin callers never see member + * emails surfaced by admin-authored data sources (e.g. a dashboard that + * lists all workspace members). + * + * Rows without `entity_type === '$member'` pass through untouched — template + * data for non-member entities is never redacted here. Row-level email + * fields at the top level (e.g. `SELECT metadata->>'email' AS email ...`) + * are also stripped when the row is clearly a member, but aggressive deep + * rewriting is intentionally avoided to keep the helper predictable. + */ +export function stripMemberEmailsFromRows( + data: Record | null, + emailField: string +): Record | null { + if (!data || !emailField) return data; + const out: Record = {}; + for (const [name, rows] of Object.entries(data)) { + out[name] = rows.map((row) => { + if (!row || typeof row !== 'object' || Array.isArray(row)) return row; + const record = row as Record; + if (record.entity_type !== '$member') return record; + const result: Record = { ...record }; + if (emailField in result) delete result[emailField]; + const metadata = result.metadata; + if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) { + const md = metadata as Record; + if (emailField in md) { + const { [emailField]: _drop, ...rest } = md; + result.metadata = rest; + } + } + return result; + }); + } + return out; +} diff --git a/packages/owletto-backend/src/utils/public-origin.ts b/packages/owletto-backend/src/utils/public-origin.ts index 8bf21850c..63847ad1b 100644 --- a/packages/owletto-backend/src/utils/public-origin.ts +++ b/packages/owletto-backend/src/utils/public-origin.ts @@ -74,6 +74,34 @@ export function hasLocalFrontend(): boolean { const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); +/** + * Normalize a host or DNS zone for comparison: + * - strip port / leading-dot / trailing-dot / surrounding whitespace + * - convert IDN labels (e.g. `müller.lobu.ai`) to ASCII punycode + * - lowercase + * + * Returns null when the input cannot be parsed as a hostname. Use this on + * both sides of any zone/host comparison so IDN-encoded requests still match + * the canonical ASCII zone configured in env. + */ +export function normalizeHost( + value: string | null | undefined +): string | null { + if (!value) return null; + const stripped = value + .split(':')[0] + ?.trim() + .replace(/^\./, '') + .replace(/\.$/, ''); + if (!stripped) return null; + try { + // new URL() performs IDN → punycode conversion and lowercasing. + return new URL(`https://${stripped}`).hostname; + } catch { + return null; + } +} + /** * Returns a canonical redirect URL when browser traffic lands on a non-canonical * host while a public origin is configured. Subdomains of the canonical host are @@ -98,8 +126,9 @@ export function getCanonicalRedirectUrl( return null; } - const requestHost = request.hostname.toLowerCase(); - const canonicalHost = canonical.hostname.toLowerCase(); + // URL already lowercases + punycodes hostnames, so both sides are comparable. + const requestHost = request.hostname; + const canonicalHost = canonical.hostname; if (LOCALHOST_HOSTNAMES.has(requestHost)) return null; if (request.origin === canonical.origin) return null; @@ -107,7 +136,7 @@ export function getCanonicalRedirectUrl( return null; } - const cookieZone = cookieDomain?.trim().replace(/^\./, '').toLowerCase(); + const cookieZone = normalizeHost(cookieDomain); if (cookieZone && (requestHost === cookieZone || requestHost.endsWith(`.${cookieZone}`))) { return null; } @@ -127,12 +156,12 @@ export function getSubdomainZone( configuredOrigin = getConfiguredPublicOrigin(), cookieDomain = process.env.AUTH_COOKIE_DOMAIN ): string | null { - const cookieZone = cookieDomain?.trim().replace(/^\./, '').toLowerCase(); + const cookieZone = normalizeHost(cookieDomain); if (cookieZone) return cookieZone; if (!configuredOrigin) return null; try { - return new URL(configuredOrigin).hostname.toLowerCase(); + return normalizeHost(new URL(configuredOrigin).hostname); } catch { return null; } @@ -149,11 +178,12 @@ export function extractSubdomainOrg( zone: string | null | undefined, reservedSubdomains: ReadonlySet ): string | null { - if (!host || !zone) return null; - const normalizedHost = host.split(':')[0]?.toLowerCase(); - if (!normalizedHost || !normalizedHost.endsWith(`.${zone}`)) return null; + const normalizedHost = normalizeHost(host); + const normalizedZone = normalizeHost(zone); + if (!normalizedHost || !normalizedZone) return null; + if (!normalizedHost.endsWith(`.${normalizedZone}`)) return null; - const sub = normalizedHost.slice(0, -(zone.length + 1)); + const sub = normalizedHost.slice(0, -(normalizedZone.length + 1)); if (!sub || sub.includes('.') || reservedSubdomains.has(sub)) return null; return sub; } diff --git a/packages/worker/src/shared/tool-implementations.ts b/packages/worker/src/shared/tool-implementations.ts index 2aace1001..5f3018c08 100644 --- a/packages/worker/src/shared/tool-implementations.ts +++ b/packages/worker/src/shared/tool-implementations.ts @@ -213,8 +213,20 @@ export async function uploadUserFile( ); } const filePath = path.isAbsolute(args.file_path) - ? args.file_path - : path.join(gw.workspaceDir as string, args.file_path); + ? path.resolve(args.file_path) + : path.resolve(gw.workspaceDir as string, args.file_path); + + if (gw.workspaceDir) { + const workspaceRoot = path.resolve(gw.workspaceDir); + if ( + filePath !== workspaceRoot && + !filePath.startsWith(workspaceRoot + path.sep) + ) { + return textResult( + `Error: Refusing to read file outside the workspace: ${args.file_path}` + ); + } + } const stats = await fs.stat(filePath).catch(() => null); if (!stats?.isFile()) {