瀏覽代碼

fix(ai): address Alibaba image audit findings

Aiden Cline 3 周之前
父節點
當前提交
0baabcdc4b

+ 19 - 17
packages/ai/src/protocols/alibaba-images.ts

@@ -25,7 +25,7 @@ export const PATH = "/services/aigc/multimodal-generation/generation"
 
 export type Family = "qwen" | "wan"
 
-export interface QwenImageOptions extends Record<string, unknown> {
+export interface QwenImageOptions {
   readonly negativePrompt?: string
   readonly promptExtend?: boolean
   readonly watermark?: boolean
@@ -36,14 +36,14 @@ export interface WanColor {
   readonly ratio: string
 }
 
-export interface WanImageOptions extends Record<string, unknown> {
+export interface WanImageOptions {
   readonly resolution?: "1K" | "2K" | "4K"
   readonly thinkingMode?: boolean
   readonly colorPalette?: ReadonlyArray<WanColor>
   readonly watermark?: boolean
 }
 
-export interface AlibabaImageOptions extends Record<string, unknown> {
+export interface AlibabaImageOptions {
   readonly qwen?: QwenImageOptions
   readonly wan?: WanImageOptions
 }
@@ -240,7 +240,8 @@ const expiration = (url: string) => {
   if (!URL.canParse(url)) return undefined
   const value = new URL(url).searchParams.get("Expires")
   if (value === null || !Number.isFinite(Number(value))) return undefined
-  return new Date(Number(value) * 1000).toISOString()
+  const date = new Date(Number(value) * 1000)
+  return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
 }
 
 export const model = (input: ModelInput) => {
@@ -297,20 +298,21 @@ export const model = (input: ModelInput) => {
         return yield* invalidOutput("Alibaba Images returned no images", { requestId: decoded.request_id })
 
       return new ImageResponse({
-        images: urls.map(
-          (url) =>
-            new GeneratedImage({
-              mediaType: "image/png",
-              data: url,
-              providerMetadata: {
-                alibaba: {
-                  modelId: request.model.id,
-                  family: input.family,
-                  expiresAt: expiration(url),
-                },
+        images: urls.map((url) => {
+          const expiresAt = expiration(url)
+          return new GeneratedImage({
+            mediaType: "image/png",
+            data: url,
+            expiresAt,
+            providerMetadata: {
+              alibaba: {
+                modelId: request.model.id,
+                family: input.family,
+                expiresAt,
               },
-            }),
-        ),
+            },
+          })
+        }),
         usage:
           decoded.usage === undefined
             ? undefined

+ 7 - 0
packages/ai/test/auth-options.types.ts

@@ -2,6 +2,7 @@ import { Config } from "effect"
 import { Auth } from "../src/route"
 import type { ModelFactory } from "../src/route/auth-options"
 import * as OpenAIChat from "../src/protocols/openai-chat"
+import * as Alibaba from "../src/providers/alibaba"
 import * as AmazonBedrock from "../src/providers/amazon-bedrock"
 import * as Anthropic from "../src/providers/anthropic"
 import * as AnthropicCompatible from "../src/providers/anthropic-compatible"
@@ -250,3 +251,9 @@ Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key
 GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1")
 // @ts-expect-error GitHub Copilot model selectors only accept model ids.
 GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {})
+
+Alibaba.configure({ image: { providerOptions: { qwen: { promptExtend: true } } } }).image("qwen-image-2.0")
+// @ts-expect-error Alibaba Qwen options are closed and reject unimplemented parameters.
+Alibaba.configure({ image: { providerOptions: { qwen: { style: "photorealistic" } } } })
+// @ts-expect-error Alibaba Wan options are closed and reject unimplemented parameters.
+Alibaba.configure({ image: { providerOptions: { wan: { parameters: { watermark: true } } } } })

+ 2 - 2
packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json

@@ -3,7 +3,7 @@
   "metadata": {
     "tags": ["prefix:alibaba-images", "provider:alibaba", "protocol:alibaba-images"],
     "name": "alibaba-images/generates-with-qwen-image-2-0",
-    "recordedAt": "2026-07-19T16:05:19.642Z"
+    "recordedAt": "2026-07-19T16:15:36.863Z"
   },
   "interactions": [
     {
@@ -21,7 +21,7 @@
         "headers": {
           "content-type": "application/json"
         },
-        "body": "{\"output\":{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"image\":\"https://dashscope-463f.oss-accelerate.aliyuncs.com/7d/7b/20260720/991d10e3/fe425a64-dc6f-4c67-a4fa-a06810984ba9.png?Expires=1785082919&OSSAccessKeyId=fixture&Signature=fixture\"}],\"role\":\"assistant\"}}]},\"usage\":{\"height\":512,\"image_count\":1,\"width\":512},\"request_id\":\"484490f4-9881-90eb-8d52-d270d2395920\"}"
+        "body": "{\"output\":{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"image\":\"https://dashscope-463f.oss-accelerate.aliyuncs.com/7d/04/20260720/991d10e3/93325a03-aa95-4dd3-a395-8d7246b5e72c.png?Expires=1785083536&OSSAccessKeyId=%5BREDACTED%5D&Signature=%5BREDACTED%5D\"}],\"role\":\"assistant\"}}]},\"usage\":{\"height\":512,\"image_count\":1,\"width\":512},\"request_id\":\"9f94d544-393b-9d6f-b005-4d0ff21f6fdf\"}"
       }
     }
   ]

+ 2 - 2
packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json

@@ -3,7 +3,7 @@
   "metadata": {
     "tags": ["prefix:alibaba-images", "provider:alibaba", "protocol:alibaba-images"],
     "name": "alibaba-images/generates-with-wan-2-7",
-    "recordedAt": "2026-07-19T16:05:23.617Z"
+    "recordedAt": "2026-07-19T16:15:40.689Z"
   },
   "interactions": [
     {
@@ -21,7 +21,7 @@
         "headers": {
           "content-type": "application/json"
         },
-        "body": "{\"output\":{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"image\":\"https://dashscope-463f.oss-accelerate.aliyuncs.com/1d/ae/20260720/10739b93/1784477122_595381b5.png?Expires=1784563522&OSSAccessKeyId=fixture&Signature=fixture\",\"type\":\"image\"}],\"role\":\"assistant\"}}],\"finished\":true},\"usage\":{\"image_count\":1,\"input_tokens\":31,\"output_tokens\":2,\"size\":\"1024*1024\",\"total_tokens\":33},\"request_id\":\"ec5cacb5-c7b0-9e46-8459-49003a3816c3\"}"
+        "body": "{\"output\":{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"image\":\"https://dashscope-463f.oss-accelerate.aliyuncs.com/1d/0b/20260720/10739b93/1784477739_553aee1e.png?Expires=1784564140&OSSAccessKeyId=%5BREDACTED%5D&Signature=%5BREDACTED%5D\",\"type\":\"image\"}],\"role\":\"assistant\"}}],\"finished\":true},\"usage\":{\"image_count\":1,\"input_tokens\":31,\"output_tokens\":2,\"size\":\"1024*1024\",\"total_tokens\":33},\"request_id\":\"3eb93323-2d8f-9aa5-b010-82dd8fab80ea\"}"
       }
     }
   ]

+ 68 - 2
packages/ai/test/provider/alibaba-images.recorded.test.ts

@@ -1,5 +1,5 @@
-import { describe, expect } from "bun:test"
-import { Effect } from "effect"
+import { describe, expect, test } from "bun:test"
+import { Effect, Option, Schema } from "effect"
 import { Image } from "../../src"
 import { Alibaba } from "../../src/providers"
 import { recordedTests } from "../recorded-test"
@@ -8,11 +8,77 @@ const alibaba = Alibaba.configure({
   apiKey: process.env.DASHSCOPE_API_KEY ?? "fixture",
 })
 
+const SIGNED_QUERY_CREDENTIALS = new Set(
+  [
+    "AccessKeyId",
+    "AWSAccessKeyId",
+    "GoogleAccessId",
+    "OSSAccessKeyId",
+    "Signature",
+    "SecurityToken",
+    "X-Amz-Credential",
+    "X-Amz-Security-Token",
+    "X-Amz-Signature",
+    "X-Goog-Credential",
+    "X-Goog-Signature",
+    "X-OSS-Security-Token",
+  ].map((name) => name.replace(/[^a-z0-9]/gi, "").toLowerCase()),
+)
+
+const redactSignedUrl = (value: string) => {
+  if (!URL.canParse(value)) return value
+  const url = new URL(value)
+  if (url.protocol !== "http:" && url.protocol !== "https:") return value
+  url.searchParams.forEach((_, name) => {
+    if (SIGNED_QUERY_CREDENTIALS.has(name.replace(/[^a-z0-9]/gi, "").toLowerCase()))
+      url.searchParams.set(name, "[REDACTED]")
+  })
+  return url.toString()
+}
+
+const redactSignedUrls = (body: string) =>
+  Option.match(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(body), {
+    onNone: () => body,
+    onSome: (value) =>
+      JSON.stringify(
+        (function redact(input: unknown): unknown {
+          if (typeof input === "string") return redactSignedUrl(input)
+          if (Array.isArray(input)) return input.map(redact)
+          if (input === null || typeof input !== "object") return input
+          return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, redact(child)]))
+        })(value),
+      ),
+  })
+
 const recorded = recordedTests({
   prefix: "alibaba-images",
   provider: "alibaba",
   protocol: "alibaba-images",
   requires: ["DASHSCOPE_API_KEY"],
+  options: { redact: { body: redactSignedUrls } },
+})
+
+test("recorder redacts signed URL credentials in nested JSON strings repeatably", () => {
+  const body = JSON.stringify({
+    output: {
+      image:
+        "https://example.oss.aliyuncs.com/image.png?Expires=1893456000&OSSAccessKeyId=access&Signature=signature&x-oss-security-token=token",
+    },
+    mirrors: ["https://storage.googleapis.com/image.png?X-Goog-Credential=credential&X-Goog-Signature=signature"],
+  })
+  const once = redactSignedUrls(body)
+  const twice = redactSignedUrls(once)
+  const parsed = JSON.parse(once)
+  const oss = new URL(parsed.output.image)
+  const gcs = new URL(parsed.mirrors[0])
+
+  expect(oss.searchParams.get("Expires")).toBe("1893456000")
+  expect(oss.searchParams.get("OSSAccessKeyId")).toBe("[REDACTED]")
+  expect(oss.searchParams.get("Signature")).toBe("[REDACTED]")
+  expect(oss.searchParams.get("x-oss-security-token")).toBe("[REDACTED]")
+  expect(gcs.searchParams.get("X-Goog-Credential")).toBe("[REDACTED]")
+  expect(gcs.searchParams.get("X-Goog-Signature")).toBe("[REDACTED]")
+  expect(twice).toBe(once)
 })
 
 describe("Alibaba Images recorded", () => {

+ 51 - 0
packages/ai/test/provider/alibaba-images.test.ts

@@ -50,6 +50,7 @@ describe("Alibaba Images", () => {
       })
 
       expect(result.image?.data).toContain("dashscope-result-intl")
+      expect(result.image?.expiresAt).toBe("2030-01-01T00:00:00.000Z")
       expect(result.image?.providerMetadata).toEqual({
         alibaba: {
           modelId: "qwen-image-2.0-pro",
@@ -193,4 +194,54 @@ describe("Alibaba Images", () => {
       ),
     ),
   )
+
+  it.effect("rejects parameters overlays owned by the protocol", () =>
+    Image.generate({
+      model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"),
+      prompt: "A robot",
+      http: { body: { parameters: { watermark: true } } },
+    }).pipe(
+      Effect.flip,
+      Effect.tap((error) =>
+        Effect.sync(() => {
+          expect(error.reason._tag).toBe("InvalidRequest")
+          expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): parameters")
+        }),
+      ),
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse(() => Effect.die("reserved body validation must happen before the request is sent")),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("ignores finite expiration values outside the Date range", () =>
+    Effect.gen(function* () {
+      const result = yield* Image.generate({
+        model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"),
+        prompt: "A robot",
+      })
+
+      expect(result.image?.expiresAt).toBeUndefined()
+      expect(result.image?.providerMetadata?.alibaba?.expiresAt).toBeUndefined()
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              const payload = response("qwen")
+              payload.output.choices[0].message.content[0].image =
+                "https://dashscope-result-intl.oss-cn-singapore.aliyuncs.com/result.png?Expires=9007199254740991"
+              return Effect.succeed(
+                input.respond(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
 })