fix(wechat): enforce current-turn image paths in page delivery
Memind CI / Test, build, and release guards (push) Successful in 6m58s
Memind CI / Test, build, and release guards (push) Successful in 6m58s
Materialize wechat-mp uploads into dated public/images paths during vision preprocessing and fail closed when delivered HTML references stale workspace images instead of the current turn allowlist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { extractImageAssetKey } from './chat-image-turn-scope.mjs';
|
||||
|
||||
const WECHAT_MP_PUBLIC_PATH_RE = /\/public\/(wechat-mp\/[^?#\s"'<>]+)/i;
|
||||
const MINDSPACE_PUBLIC_PATH_RE =
|
||||
/\/MindSpace\/[0-9a-f-]{36}\/public\/(wechat-mp\/[^?#\s"'<>]+|images\/[^?#\s"'<>]+)/i;
|
||||
const HTML_IMG_SRC_RE = /\bsrc\s*=\s*["']([^"']+)["']/gi;
|
||||
const HTML_COVER_JSON_RE = /name=["']mindspace-cover["']\s+content=['"]([^'"]+)['"]/i;
|
||||
|
||||
function formatUtcDateStamp(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function extractPublicZoneRelativePath(rawUrl, userId = '') {
|
||||
const value = String(rawUrl ?? '').trim();
|
||||
if (!value) return null;
|
||||
const mindspaceMatch = value.match(MINDSPACE_PUBLIC_PATH_RE);
|
||||
if (mindspaceMatch?.[1]) {
|
||||
return `public/${mindspaceMatch[1]}`;
|
||||
}
|
||||
const wechatMatch = value.match(WECHAT_MP_PUBLIC_PATH_RE);
|
||||
if (wechatMatch?.[1]) {
|
||||
return `public/${wechatMatch[1]}`;
|
||||
}
|
||||
if (value.startsWith('public/wechat-mp/') || value.startsWith('public/images/')) {
|
||||
return value;
|
||||
}
|
||||
if (value.startsWith('wechat-mp/') || value.startsWith('images/')) {
|
||||
return `public/${value}`;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(
|
||||
value,
|
||||
value.startsWith('/') ? 'http://local' : undefined,
|
||||
);
|
||||
const pathname = parsed.pathname;
|
||||
const publicIndex = pathname.indexOf('/public/');
|
||||
if (publicIndex >= 0) {
|
||||
const tail = pathname.slice(publicIndex + '/public/'.length);
|
||||
if (tail.startsWith('wechat-mp/') || tail.startsWith('images/')) {
|
||||
return `public/${tail}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
void userId;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl,
|
||||
buffer = null,
|
||||
mimeType = 'image/jpeg',
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const publicRelativePath = extractPublicZoneRelativePath(rawUrl);
|
||||
if (!publicRelativePath || !String(publishDir ?? '').trim()) {
|
||||
return null;
|
||||
}
|
||||
const sourceAbs = path.join(publishDir, publicRelativePath);
|
||||
let sourceBuffer = buffer;
|
||||
if (!sourceBuffer) {
|
||||
try {
|
||||
if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) {
|
||||
return null;
|
||||
}
|
||||
sourceBuffer = fs.readFileSync(sourceAbs);
|
||||
if (!mimeType) mimeType = 'image/jpeg';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!Buffer.isBuffer(sourceBuffer) || sourceBuffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (publicRelativePath.startsWith('public/images/')) {
|
||||
const embedPath = publicRelativePath.slice('public/'.length);
|
||||
return {
|
||||
publicRelativePath,
|
||||
relativeEmbedPath: embedPath,
|
||||
embedUrl: embedPath,
|
||||
assetKeys: collectEmbedAssetKeys([embedPath, publicRelativePath, rawUrl]),
|
||||
materialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!publicRelativePath.startsWith('public/wechat-mp/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateDir = formatUtcDateStamp(now);
|
||||
const hash = crypto.createHash('md5').update(sourceBuffer).digest('hex').slice(0, 8);
|
||||
const basename = path.posix.basename(publicRelativePath);
|
||||
const destPublicRelativePath = `public/images/${dateDir}/${hash}-${basename}`;
|
||||
const destAbs = path.join(publishDir, destPublicRelativePath);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
||||
if (!fs.existsSync(destAbs)) {
|
||||
fs.writeFileSync(destAbs, sourceBuffer);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const embedPath = destPublicRelativePath.slice('public/'.length);
|
||||
return {
|
||||
publicRelativePath: destPublicRelativePath,
|
||||
relativeEmbedPath: embedPath,
|
||||
embedUrl: embedPath,
|
||||
assetKeys: collectEmbedAssetKeys([embedPath, destPublicRelativePath, rawUrl]),
|
||||
materialized: true,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectEmbedAssetKeys(values = []) {
|
||||
const keys = new Set();
|
||||
for (const value of values) {
|
||||
const key = extractImageAssetKey(value);
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function buildAllowedPageImageEmbedKeys({
|
||||
publishDir,
|
||||
imageUrls = [],
|
||||
userId = '',
|
||||
} = {}) {
|
||||
const allowed = new Set();
|
||||
for (const rawUrl of imageUrls) {
|
||||
const materialized = materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl,
|
||||
userId,
|
||||
});
|
||||
if (materialized?.assetKeys) {
|
||||
for (const key of materialized.assetKeys) allowed.add(key);
|
||||
} else {
|
||||
for (const key of collectEmbedAssetKeys([rawUrl])) allowed.add(key);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export function extractHtmlImageSourceKeys(html) {
|
||||
const keys = new Set();
|
||||
const value = String(html ?? '');
|
||||
if (!value) return keys;
|
||||
|
||||
for (const match of value.matchAll(HTML_IMG_SRC_RE)) {
|
||||
for (const key of collectEmbedAssetKeys([match[1]])) keys.add(key);
|
||||
}
|
||||
|
||||
const coverMatch = value.match(HTML_COVER_JSON_RE);
|
||||
if (coverMatch?.[1]) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
coverMatch[1]
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'"),
|
||||
);
|
||||
for (const key of collectEmbedAssetKeys([parsed?.cover, parsed?.image])) {
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
} catch {
|
||||
for (const key of collectEmbedAssetKeys([coverMatch[1]])) keys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function verifyHtmlImageSourcesAllowed(html, allowedKeys) {
|
||||
const allowed = allowedKeys instanceof Set ? allowedKeys : new Set(allowedKeys);
|
||||
if (allowed.size === 0) {
|
||||
return { ok: true, reason: null, offendingKeys: [] };
|
||||
}
|
||||
const found = extractHtmlImageSourceKeys(html);
|
||||
if (found.size === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing_required_images',
|
||||
offendingKeys: [],
|
||||
};
|
||||
}
|
||||
const offendingKeys = [...found].filter((key) => !allowed.has(key));
|
||||
if (offendingKeys.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'stale_image_source',
|
||||
offendingKeys,
|
||||
};
|
||||
}
|
||||
return { ok: true, reason: null, offendingKeys: [] };
|
||||
}
|
||||
|
||||
export const chatImageMaterializeInternals = {
|
||||
formatUtcDateStamp,
|
||||
HTML_IMG_SRC_RE,
|
||||
};
|
||||
Reference in New Issue
Block a user