diff --git a/runtime/typescript/packages/anthropic/package.json b/runtime/typescript/packages/anthropic/package.json index b03c62f98..908db1468 100644 --- a/runtime/typescript/packages/anthropic/package.json +++ b/runtime/typescript/packages/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@prompty/anthropic", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "Anthropic provider for Prompty — executor and processor for Anthropic Messages API", "type": "module", "main": "./dist/index.cjs", @@ -56,11 +56,14 @@ }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", - "@prompty/core": "^2.0.0-beta.4", + "@prompty/core": "^2.0.0-beta.5", "@types/node": "^20.11.0", "dotenv": "^16.4.0", "tsup": "^8.4.0", "typescript": "^5.7.0", "vitest": "^4.1.8" + }, + "dependencies": { + "@prompty/core": "^2.0.0-beta.4" } } diff --git a/runtime/typescript/packages/core/package.json b/runtime/typescript/packages/core/package.json index 806e75002..95b96000d 100644 --- a/runtime/typescript/packages/core/package.json +++ b/runtime/typescript/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@prompty/core", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "Prompty core runtime — load, render, parse, and trace .prompty files", "type": "module", "main": "./dist/index.cjs", @@ -60,8 +60,8 @@ }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@prompty/anthropic": "^2.0.0-beta.4", - "@prompty/openai": "^2.0.0-beta.4", + "@prompty/anthropic": "^2.0.0-beta.5", + "@prompty/openai": "^2.0.0-beta.5", "@types/mustache": "^4.2.5", "@types/node": "^20.11.0", "@types/nunjucks": "^3.2.6", diff --git a/runtime/typescript/packages/core/src/renderers/nunjucks.ts b/runtime/typescript/packages/core/src/renderers/nunjucks.ts index c1ad8e918..aabcef453 100644 --- a/runtime/typescript/packages/core/src/renderers/nunjucks.ts +++ b/runtime/typescript/packages/core/src/renderers/nunjucks.ts @@ -13,11 +13,91 @@ import type { Prompty } from "../model/agent/prompty.js"; import type { Renderer } from "../core/interfaces.js"; import { prepareRenderInputs } from "./common.js"; +type NunjucksRuntime = { + memberLookup: (object: unknown, property: unknown) => unknown; + callWrap: (callable: unknown, name: string, context: unknown, args: unknown[]) => unknown; +}; + +const UNSAFE_PROPERTIES = new Set(["__proto__", "constructor", "prototype"]); + const env = new nunjucks.Environment(null, { autoescape: false, throwOnUndefined: false, }); +function safeMemberLookup(object: unknown, property: unknown): unknown { + if (typeof property === "string" && UNSAFE_PROPERTIES.has(property)) { + throw new Error(`Unsafe template member access: ${property}`); + } + + if ( + (typeof property !== "string" && typeof property !== "number") || + object === null || + typeof object !== "object" + ) { + return undefined; + } + + const descriptor = Object.getOwnPropertyDescriptor(object, property); + return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined; +} + +function safeCallWrap(_callable: unknown, name: string, _context: unknown, _args: unknown[]): never { + throw new Error(`Template function calls are not allowed: ${name}`); +} + +function sanitizeValue(value: unknown, seen = new WeakMap()): unknown { + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (typeof value !== "object") { + return undefined; + } + + const existing = seen.get(value); + if (existing !== undefined) { + return existing; + } + + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value, result); + for (const item of value) { + result.push(sanitizeValue(item, seen)); + } + return result; + } + + const result = Object.create(null) as Record; + seen.set(value, result); + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { + if (!UNSAFE_PROPERTIES.has(key) && "value" in descriptor) { + result[key] = sanitizeValue(descriptor.value, seen); + } + } + return result; +} + +function sanitizeInputs(inputs: Record): Record { + return sanitizeValue(inputs) as Record; +} + +function renderSafely(template: string, inputs: Record): string { + const runtime = nunjucks.runtime as unknown as NunjucksRuntime; + const memberLookup = runtime.memberLookup; + const callWrap = runtime.callWrap; + runtime.memberLookup = safeMemberLookup; + runtime.callWrap = safeCallWrap; + + try { + return env.renderString(template, inputs); + } finally { + runtime.memberLookup = memberLookup; + runtime.callWrap = callWrap; + } +} + export class NunjucksRenderer implements Renderer { async render( agent: Prompty, @@ -25,6 +105,6 @@ export class NunjucksRenderer implements Renderer { inputs: Record, ): Promise { const [modified] = prepareRenderInputs(agent, inputs); - return env.renderString(template, modified); + return renderSafely(template, sanitizeInputs(modified)); } } diff --git a/runtime/typescript/packages/core/tests/renderers.test.ts b/runtime/typescript/packages/core/tests/renderers.test.ts index a9e7d739c..1e523d8e6 100644 --- a/runtime/typescript/packages/core/tests/renderers.test.ts +++ b/runtime/typescript/packages/core/tests/renderers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { NunjucksRenderer } from "../src/renderers/nunjucks.js"; import { MustacheRenderer } from "../src/renderers/mustache.js"; import { Prompty } from "@prompty/core"; @@ -29,6 +29,30 @@ describe("NunjucksRenderer", () => { const result = await renderer.render(agent, template, { items: ["a", "b", "c"] }); expect(result.trim()).toBe("a b c"); }); + + it("renders own nested data properties", async () => { + const result = await renderer.render(agent, "{{ customer.name }}", { + customer: { name: "Ada" }, + }); + expect(result).toBe("Ada"); + }); + + it.each(["{{ value.constructor }}", "{{ value.__proto__ }}", "{{ value.prototype }}"])( + "rejects unsafe member access: %s", + async (template) => { + await expect(renderer.render(agent, template, { value: "test" })).rejects.toThrow( + "Unsafe template member access", + ); + }, + ); + + it("rejects template function calls without invoking the input function", async () => { + const callback = vi.fn(); + await expect(renderer.render(agent, "{{ callback() }}", { callback })).rejects.toThrow( + "Template function calls are not allowed", + ); + expect(callback).not.toHaveBeenCalled(); + }); }); describe("MustacheRenderer", () => { diff --git a/runtime/typescript/packages/foundry/package.json b/runtime/typescript/packages/foundry/package.json index f57250d3f..22a7e4b67 100644 --- a/runtime/typescript/packages/foundry/package.json +++ b/runtime/typescript/packages/foundry/package.json @@ -1,6 +1,6 @@ { "name": "@prompty/foundry", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "Microsoft Foundry provider for Prompty — executor and processor for Azure AI Foundry", "type": "module", "main": "./dist/index.cjs", @@ -52,11 +52,13 @@ "dependencies": { "@azure/ai-projects": "^2.0.1", "@azure/identity": "^4.13.1", + "@prompty/core": "^2.0.0-beta.4", + "@prompty/openai": "^2.0.0-beta.4", "openai": "^4.80.0" }, "devDependencies": { - "@prompty/core": "^2.0.0-beta.4", - "@prompty/openai": "^2.0.0-beta.4", + "@prompty/core": "^2.0.0-beta.5", + "@prompty/openai": "^2.0.0-beta.5", "@types/node": "^20.11.0", "dotenv": "^16.4.0", "tsup": "^8.4.0", diff --git a/runtime/typescript/packages/openai/package.json b/runtime/typescript/packages/openai/package.json index 18ec386c3..bf643099e 100644 --- a/runtime/typescript/packages/openai/package.json +++ b/runtime/typescript/packages/openai/package.json @@ -1,6 +1,6 @@ { "name": "@prompty/openai", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "OpenAI provider for Prompty — executor and processor for OpenAI APIs", "type": "module", "main": "./dist/index.cjs", @@ -48,10 +48,11 @@ "@prompty/core": "^2.0.0-beta.4" }, "dependencies": { + "@prompty/core": "^2.0.0-beta.4", "openai": "^4.80.0" }, "devDependencies": { - "@prompty/core": "^2.0.0-beta.4", + "@prompty/core": "^2.0.0-beta.5", "@types/node": "^20.11.0", "dotenv": "^16.4.0", "tsup": "^8.4.0",