fix(markdown): align image gallery authorization

This commit is contained in:
Bohdan Triapitsyn
2026-08-14 17:27:07 +03:00
parent 90780258cd
commit 12b5857b3f
8 changed files with 397 additions and 47 deletions
@@ -14,6 +14,9 @@ different machine.
- `POST /api/openchamber/sessions/:sessionId/markdown-image-grants` prepares up to 12
local images in one message-level request. The server fetches the assistant
message once and verifies every exact image source before reading files.
- Authorization recognizes the same common inline and reference-style image
destinations collected by the UI, including balanced parentheses, while
excluding fenced and inline code.
- Relative and workspace-contained absolute paths resolve against the active
directory. Other absolute paths are accepted only inside
`os.tmpdir()/opencode` after `realpath` resolution.
@@ -27,4 +30,7 @@ different machine.
The routes are OpenChamber-owned and must be registered before the generic
OpenCode proxy. Web, Electron, hosted mobile, and Capacitor use the shared
server implementation. VS Code returns an explicit unsupported response.
server implementation. VS Code does not call this route for workspace images;
those use its local filesystem bridge. If called, the grant route returns an
explicit unsupported response because OpenCode temporary images are not
supported there.
@@ -42,25 +42,150 @@ const hasImageSignature = (bytes) => {
|| (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP');
};
const markdownImageSources = (message) => {
const sources = new Set();
const normalizeReferenceLabel = (value) => value.trim().replace(/\s+/g, ' ').toLowerCase();
const unescapeMarkdownDestination = (value) => value.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\])/g, '$1');
const isEscapedAt = (value, index) => {
let slashes = 0;
for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor -= 1) slashes += 1;
return slashes % 2 === 1;
};
const findClosingBracket = (value, start) => {
for (let cursor = start; cursor < value.length; cursor += 1) {
if (value[cursor] === ']' && !isEscapedAt(value, cursor)) return cursor;
}
return -1;
};
const findInlineImageEnd = (value, start) => {
let cursor = start;
while (/\s/.test(value[cursor] || '')) cursor += 1;
if (value[cursor] === ')') return cursor;
const opener = value[cursor];
const closer = opener === '"' ? '"' : opener === "'" ? "'" : opener === '(' ? ')' : '';
if (!closer) return -1;
cursor += 1;
for (; cursor < value.length; cursor += 1) {
if (value[cursor] !== closer || isEscapedAt(value, cursor)) continue;
cursor += 1;
while (/\s/.test(value[cursor] || '')) cursor += 1;
return value[cursor] === ')' ? cursor : -1;
}
return -1;
};
const parseInlineDestination = (value, start) => {
let cursor = start;
while (/\s/.test(value[cursor] || '')) cursor += 1;
if (value[cursor] === '<') {
const end = value.indexOf('>', cursor + 1);
if (end < 0) return null;
const imageEnd = findInlineImageEnd(value, end + 1);
return imageEnd < 0
? null
: { source: unescapeMarkdownDestination(value.slice(cursor + 1, end)), end: imageEnd };
}
let source = '';
let depth = 0;
for (; cursor < value.length; cursor += 1) {
const char = value[cursor];
if (char === '\\' && cursor + 1 < value.length) {
source += char + value[cursor + 1];
cursor += 1;
continue;
}
if (char === '(') {
depth += 1;
source += char;
continue;
}
if (char === ')') {
if (depth === 0) return { source: unescapeMarkdownDestination(source), end: cursor };
depth -= 1;
source += char;
continue;
}
if (/\s/.test(char) && depth === 0) {
const imageEnd = findInlineImageEnd(value, cursor);
return imageEnd < 0 ? null : { source: unescapeMarkdownDestination(source), end: imageEnd };
}
source += char;
}
return null;
};
const parseDefinitionDestination = (value) => {
const trimmed = value.trimStart();
if (trimmed.startsWith('<')) {
const end = trimmed.indexOf('>', 1);
return end < 0 ? '' : unescapeMarkdownDestination(trimmed.slice(1, end));
}
const match = /^(?:\\.|\S)+/.exec(trimmed);
return match ? unescapeMarkdownDestination(match[0]) : '';
};
const collectMarkdownLinesOutsideCode = (message) => {
const lines = [];
for (const part of Array.isArray(message?.parts) ? message.parts : []) {
if (part?.type !== 'text' || typeof part.text !== 'string') continue;
// Code examples must never authorize file access, even when they contain image syntax.
let fenced = false;
let fence = null;
for (const line of part.text.split('\n')) {
if (/^\s{0,3}(?:```|~~~)/.test(line)) {
fenced = !fenced;
const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
if (fenceMatch) {
const marker = fenceMatch[1];
if (!fence) {
fence = { char: marker[0], size: marker.length };
} else if (marker[0] === fence.char && marker.length >= fence.size) {
fence = null;
}
continue;
}
if (fenced) continue;
const visible = line.replace(/`+[^`]*`+/g, '');
const pattern = /(?<!\\)!\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))/g;
let match = pattern.exec(visible);
while (match) {
sources.add(match[1] || match[2]);
match = pattern.exec(visible);
if (fence) continue;
lines.push(line.replace(/`+[^`]*`+/g, ''));
}
}
return lines;
};
const markdownImageSources = (message) => {
const sources = new Set();
const markdownLines = collectMarkdownLinesOutsideCode(message);
const definitions = new Map();
for (const line of markdownLines) {
const match = /^\s{0,3}\[([^\]]+)]\s*:\s*(.*)$/.exec(line);
if (!match) continue;
const source = parseDefinitionDestination(match[2]);
if (source) definitions.set(normalizeReferenceLabel(match[1]), source);
}
for (const line of markdownLines) {
for (let cursor = 0; cursor < line.length; cursor += 1) {
if (line[cursor] !== '!' || line[cursor + 1] !== '[' || isEscapedAt(line, cursor)) continue;
const altEnd = findClosingBracket(line, cursor + 2);
if (altEnd < 0) continue;
const alt = line.slice(cursor + 2, altEnd);
const next = line[altEnd + 1];
if (next === '(') {
const parsed = parseInlineDestination(line, altEnd + 2);
if (parsed?.source) sources.add(parsed.source);
cursor = parsed?.end ?? altEnd;
continue;
}
let label = alt;
if (next === '[') {
const labelEnd = findClosingBracket(line, altEnd + 2);
if (labelEnd < 0) continue;
label = line.slice(altEnd + 2, labelEnd) || alt;
cursor = labelEnd;
} else {
cursor = altEnd;
}
const source = definitions.get(normalizeReferenceLabel(label));
if (source) sources.add(source);
}
}
return sources;
@@ -129,6 +129,52 @@ describe('session image assets', () => {
]);
});
it('authorizes reference-style image syntax using its resolved destination', async () => {
const source = 'reference.png';
const fixture = await createFixture({
sources: [source],
markdown: '![screenshot][result]\n\n[result]: reference.png',
});
await fs.writeFile(path.join(fixture.directory, source), PNG);
const response = await prepare(fixture.app, fixture.directory, fixture.sources);
expect(response.body.results).toEqual([
expect.objectContaining({ source, status: 'ready' }),
]);
});
it('authorizes inline image destinations containing balanced parentheses', async () => {
const source = 'screen(1).png';
const fixture = await createFixture({
sources: [source],
markdown: `![screenshot](${source})`,
});
await fs.writeFile(path.join(fixture.directory, source), PNG);
const response = await prepare(fixture.app, fixture.directory, fixture.sources);
expect(response.body.results).toEqual([
expect.objectContaining({ source, status: 'ready' }),
]);
});
it('requires inline image destinations with titles to close', async () => {
const sources = ['valid.png', 'malformed.png'];
const fixture = await createFixture({
sources,
markdown: '![valid](valid.png "preview")\n![malformed](malformed.png "preview"',
});
await Promise.all(sources.map((source) => fs.writeFile(path.join(fixture.directory, source), PNG)));
const response = await prepare(fixture.app, fixture.directory, sources);
expect(response.body.results).toEqual([
expect.objectContaining({ source: 'valid.png', status: 'ready' }),
{ source: 'malformed.png', status: 'error' },
]);
});
it('rejects a source that the message does not reference', async () => {
const fixture = await createFixture({ markdown: 'No image here.' });
const response = await prepare(fixture.app, fixture.directory, fixture.sources);