mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(daemon): keep proxy Basic credentials off the export tool-token lane
authorizeExportRead treated any Authorization header that was not the
daemon API token as a run-scoped agent tool-token call. Tool tokens are
only ever parsed out of a Bearer header (bearerTokenFromRequest), so a
reverse proxy that authenticates browsers itself and forwards its own
`Authorization: Basic ...` (Coolify native basic auth, Traefik basicAuth
without header stripping) could never satisfy that lane: every browser
export and archive request failed 401 TOOL_TOKEN_MISSING.
Gate the tool lane on the Bearer scheme so foreign Basic credentials fall
through to project authorization like a header-free browser request.
Foreign Bearer tokens still fail closed in the registry.
Covers export/{html,pptx,pdf-image,image}, the generic export route,
archive, archive/batch and the inline-HTML route, which all share the
helper.
Root-cause analysis and the Bearer-gate approach are @airano-ir's, from
the #7819 report.
Fixes #7819
Co-Authored-By: airano <244096521+airano-ir@users.noreply.github.com>
* fix(daemon): fail closed on a malformed Bearer credential
Carries the review finding from #8006 across to the export lane, which had
the same predicate. `authorizationHeader.trim()` reduces `Authorization:
Bearer ` to `Bearer`, which `/^Bearer\s+/i` rejected, downgrading a
malformed Bearer credential to browser project authority instead of failing
closed with TOOL_TOKEN_MISSING.
Classify the scheme independently of whether a token follows it. Regression
coverage added for `Bearer ` and a bare `Bearer`.
---------
Co-authored-by: airano <244096521+airano-ir@users.noreply.github.com>
608 lines
24 KiB
TypeScript
608 lines
24 KiB
TypeScript
import { execFile, execFileSync } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
DesktopExportArtifactInput,
|
|
DesktopExportArtifactResult,
|
|
DesktopRenderSlidesInput,
|
|
DesktopRenderSlidesResult,
|
|
} from '@open-design/sidecar-proto';
|
|
import {
|
|
closeDatabase,
|
|
ensureWorkspaceProject,
|
|
insertProject,
|
|
openDatabase,
|
|
} from '../src/db.js';
|
|
import { startServer } from '../src/server.js';
|
|
import { toolTokenRegistry } from '../src/tool-tokens.js';
|
|
|
|
const execFileP = promisify(execFile);
|
|
const daemonRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
|
const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
|
const tsxCli = path.join(repoRoot, 'node_modules/tsx/dist/cli.mjs');
|
|
const png = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
const rendererCss = Buffer.from('main { color: rgb(12, 34, 56); }\n');
|
|
const rendererImage = Buffer.from('renderer-image-exact-bytes');
|
|
const daemonApiToken = 'configured-daemon-api-token';
|
|
const legacyBaseHref = 'https://external.invalid/legacy/';
|
|
const rendererStylesheetPath = 'styles/export.css';
|
|
const rendererImagePath = 'assets/hero.png';
|
|
|
|
describe('od export run-scoped project authority', () => {
|
|
let authorityServer: http.Server;
|
|
let daemonShutdown: () => Promise<void> | void;
|
|
let daemonUrl = '';
|
|
let lastRendererAssetUrl = '';
|
|
let rendererBlockingFile = '';
|
|
let pendingRenderer: {
|
|
readonly promise: Promise<DesktopRenderSlidesResult>;
|
|
readonly signalInvocation: () => void;
|
|
} | null = null;
|
|
let outputDir = '';
|
|
const projectId = `project_${randomUUID()}`;
|
|
const foreignProjectId = `project_${randomUUID()}`;
|
|
const unboundProjectId = `project_${randomUUID()}`;
|
|
const workspaceId = `workspace_${randomUUID()}`;
|
|
const memberId = `member_${randomUUID()}`;
|
|
const boundProjectHtml = `<!doctype html><html><head><base href="${legacyBaseHref}"><link rel="stylesheet" href="${rendererStylesheetPath}"></head><body><main data-renderer-asset-authority>${projectId}</main><img src="${rendererImagePath}" alt=""></body></html>`;
|
|
|
|
beforeAll(async () => {
|
|
outputDir = await mkdtemp(path.join(os.tmpdir(), 'od-export-tool-token-'));
|
|
const dataDir = process.env.OD_DATA_DIR;
|
|
if (!dataDir) throw new Error('OD_DATA_DIR is required by the daemon test harness');
|
|
const db = openDatabase(process.cwd(), { dataDir });
|
|
const now = Date.now();
|
|
insertProject(db, {
|
|
id: projectId,
|
|
name: 'Token-bound export project',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
insertProject(db, {
|
|
id: foreignProjectId,
|
|
name: 'Foreign export project',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
insertProject(db, {
|
|
id: unboundProjectId,
|
|
name: 'Unbound export project',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
ensureWorkspaceProject(db, {
|
|
projectId,
|
|
workspaceId,
|
|
visibility: 'team',
|
|
createdByWorkspaceMemberId: memberId,
|
|
});
|
|
ensureWorkspaceProject(db, {
|
|
projectId: foreignProjectId,
|
|
workspaceId: 'foreign-workspace',
|
|
visibility: 'team',
|
|
createdByWorkspaceMemberId: 'foreign-member',
|
|
});
|
|
for (const id of [projectId, foreignProjectId, unboundProjectId]) {
|
|
const projectDir = path.join(dataDir, 'projects', id);
|
|
await mkdir(projectDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(projectDir, 'index.html'),
|
|
id === projectId
|
|
? boundProjectHtml
|
|
: `<main>${id}</main>`,
|
|
);
|
|
if (id === projectId) {
|
|
await mkdir(path.join(projectDir, 'styles'), { recursive: true });
|
|
await mkdir(path.join(projectDir, 'assets'), { recursive: true });
|
|
await writeFile(path.join(projectDir, 'styles', 'export.css'), rendererCss);
|
|
await writeFile(path.join(projectDir, 'assets', 'hero.png'), rendererImage);
|
|
}
|
|
}
|
|
|
|
authorityServer = http.createServer((_req, res) => {
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify({
|
|
items: [
|
|
{
|
|
workspaceId: 'unrelated-workspace',
|
|
workspaceName: 'Unrelated workspace',
|
|
workspaceType: 'personal',
|
|
workspaceMemberId: 'unrelated-member',
|
|
role: 'owner',
|
|
memberStatus: 'active',
|
|
lifecycleState: 'active',
|
|
},
|
|
{
|
|
workspaceId,
|
|
workspaceName: 'Exact project workspace',
|
|
workspaceType: 'team',
|
|
workspaceMemberId: memberId,
|
|
role: 'owner',
|
|
memberStatus: 'active',
|
|
lifecycleState: 'active',
|
|
},
|
|
],
|
|
}));
|
|
});
|
|
await new Promise<void>((resolve) => authorityServer.listen(0, '127.0.0.1', resolve));
|
|
const authorityAddress = authorityServer.address();
|
|
if (!authorityAddress || typeof authorityAddress === 'string') {
|
|
throw new Error('authority server did not bind');
|
|
}
|
|
vi.stubEnv('OD_WORKSPACE_CONTEXT_SOURCE', 'vela');
|
|
vi.stubEnv('VELA_CONTROL_KEY', 'test-control-key');
|
|
vi.stubEnv('VELA_API_URL', `http://127.0.0.1:${authorityAddress.port}`);
|
|
vi.stubEnv('OD_API_TOKEN', daemonApiToken);
|
|
|
|
const renderer = (input: DesktopRenderSlidesInput): Promise<DesktopRenderSlidesResult> => {
|
|
const pending = pendingRenderer;
|
|
if (pending) {
|
|
if (!input.outputDir) return Promise.resolve({ ok: false, error: 'outputDir required' });
|
|
lastRendererAssetUrl = new URL('styles/export.css', input.baseHref).href;
|
|
mkdirSync(input.outputDir, { recursive: true });
|
|
rendererBlockingFile = path.join(input.outputDir, 'settled-render.png');
|
|
execFileSync('mkfifo', [rendererBlockingFile]);
|
|
pending.signalInvocation();
|
|
return pending.promise;
|
|
}
|
|
return (async () => {
|
|
if (!input.outputDir) return { ok: false, error: 'outputDir required' };
|
|
if (input.html.includes('data-renderer-asset-authority')) {
|
|
expect(input.html).toBe(boundProjectHtml);
|
|
expect(input.baseHref).not.toBe(legacyBaseHref);
|
|
lastRendererAssetUrl = new URL(rendererStylesheetPath, input.baseHref).href;
|
|
const cssResponse = await fetch(lastRendererAssetUrl);
|
|
const cssBytes = Buffer.from(await cssResponse.arrayBuffer());
|
|
expect(cssResponse.status, cssBytes.toString()).toBe(200);
|
|
expect(cssBytes).toEqual(rendererCss);
|
|
const imageResponse = await fetch(new URL(rendererImagePath, input.baseHref));
|
|
const imageBytes = Buffer.from(await imageResponse.arrayBuffer());
|
|
expect(imageResponse.status, imageBytes.toString()).toBe(200);
|
|
expect(imageBytes).toEqual(rendererImage);
|
|
}
|
|
await mkdir(input.outputDir, { recursive: true });
|
|
const file = path.join(input.outputDir, 'export.png');
|
|
await writeFile(file, png);
|
|
return { ok: true, slideFiles: [file], width: 1, height: 1, mode: 'page' };
|
|
})();
|
|
};
|
|
const started = await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: renderer,
|
|
}) as { url: string; shutdown: () => Promise<void> | void };
|
|
daemonUrl = started.url;
|
|
daemonShutdown = started.shutdown;
|
|
vi.stubEnv('OD_API_TOKEN', 'changed-after-server-start');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await daemonShutdown?.();
|
|
await new Promise<void>((resolve) => authorityServer.close(() => resolve()));
|
|
toolTokenRegistry.clear();
|
|
closeDatabase();
|
|
vi.unstubAllEnvs();
|
|
await rm(outputDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('accepts the configured daemon API token for screenshot export', async () => {
|
|
// Given: the API token captured by the daemon at startup, before the env changed.
|
|
const request = {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${daemonApiToken}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
} as const;
|
|
|
|
// When: the configured credential requests a screenshot export.
|
|
const response = await fetch(`${daemonUrl}/api/projects/${unboundProjectId}/export/image`, request);
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
|
|
// Then: it remains on the daemon API-token lane and the export succeeds.
|
|
expect(response.status, body.toString()).toBe(200);
|
|
expect(body).toEqual(png);
|
|
});
|
|
|
|
it('exports the exact token-bound project without explicit workspace flags', async () => {
|
|
// Given: a run-scoped token for a project bound to the second directory workspace.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
}).token;
|
|
const outputPath = path.join(outputDir, 'token-bound.png');
|
|
|
|
// When: the documented spawned-agent wrapper command runs without workspace flags.
|
|
const result = await runExportCli(projectId, outputPath, token);
|
|
|
|
// Then: the exact project exports through its token authority.
|
|
expect(result.code, result.stderr).toBe(0);
|
|
expect(existsSync(outputPath)).toBe(true);
|
|
});
|
|
|
|
it('derives a bound project workspace without a tool token or workspace flags', async () => {
|
|
// Given: Harness did not forward the short-lived bearer token into its nested shell tool.
|
|
const outputPath = path.join(outputDir, 'project-id-bound.png');
|
|
|
|
// When: the documented wrapper command identifies only the project and file.
|
|
const result = await runExportCli(projectId, outputPath);
|
|
|
|
// Then: the daemon derives the exact persisted project binding instead of
|
|
// requiring the caller to know an active/default Workspace.
|
|
expect(result.code, result.stderr).toBe(0);
|
|
expect(existsSync(outputPath)).toBe(true);
|
|
});
|
|
|
|
it('accepts legacy workspace flags as ignored compatibility inputs', async () => {
|
|
// Given: an older caller still supplies stale Workspace flags.
|
|
const outputPath = path.join(outputDir, 'legacy-workspace-flags.png');
|
|
|
|
// When: it exports after the CLI contract has moved to project-id-only addressing.
|
|
const result = await runExportCli(projectId, outputPath, undefined, [
|
|
'--workspace',
|
|
'stale-workspace',
|
|
'--workspace-member',
|
|
'stale-member',
|
|
]);
|
|
|
|
// Then: parsing remains backwards compatible and the stale values do not
|
|
// override the project's persisted binding.
|
|
expect(result.code, result.stderr).toBe(0);
|
|
expect(existsSync(outputPath)).toBe(true);
|
|
});
|
|
|
|
it('loads relative renderer assets for a bound project whose HTML already declares a base', async () => {
|
|
// Given: a valid run token bound to a Workspace project whose renderer needs relative assets
|
|
// and whose source HTML already declares an unrelated external base.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
}).token;
|
|
lastRendererAssetUrl = '';
|
|
|
|
// When: the screenshot POST renders the project without forwarding its bearer to asset GETs.
|
|
const response = await fetch(`${daemonUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
|
|
// Then: the daemon hands off the original HTML plus its scoped baseHref, and the renderer
|
|
// loads exact CSS/image bytes through that scope before the PNG export succeeds;
|
|
// the renderer-only capability is revoked as soon as rendering finishes.
|
|
expect(response.status, body.toString()).toBe(200);
|
|
expect(body).toEqual(png);
|
|
expect(lastRendererAssetUrl).not.toBe('');
|
|
const revokedAssetResponse = await fetch(lastRendererAssetUrl);
|
|
expect(revokedAssetResponse.status).toBe(404);
|
|
});
|
|
|
|
it('revokes renderer asset capability as soon as the renderer promise settles', async () => {
|
|
// Given: rendering can settle while downstream file validation/read and the HTTP response remain pending.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
}).token;
|
|
let signalInvocation: (() => void) | null = null;
|
|
const invoked = new Promise<void>((resolve) => {
|
|
signalInvocation = resolve;
|
|
});
|
|
let settleRenderer = (_result: DesktopRenderSlidesResult): void => {
|
|
throw new Error('renderer settlement control was not installed');
|
|
};
|
|
const rendererPromise = new Promise<DesktopRenderSlidesResult>((resolve) => {
|
|
settleRenderer = resolve;
|
|
});
|
|
pendingRenderer = {
|
|
promise: rendererPromise,
|
|
signalInvocation: () => signalInvocation?.(),
|
|
};
|
|
lastRendererAssetUrl = '';
|
|
rendererBlockingFile = '';
|
|
|
|
// When: the route observes renderer settlement, then blocks reading the FIFO handoff.
|
|
const responsePromise = fetch(`${daemonUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
await invoked;
|
|
const settlementObserved = rendererPromise.then(() => undefined);
|
|
settleRenderer({
|
|
ok: true,
|
|
slideFiles: [rendererBlockingFile],
|
|
width: 1,
|
|
height: 1,
|
|
mode: 'page',
|
|
});
|
|
await settlementObserved;
|
|
|
|
try {
|
|
// Then: the renderer-only URL is already unusable before the route can finish its response.
|
|
expect(lastRendererAssetUrl).not.toBe('');
|
|
const revokedAssetResponse = await fetch(lastRendererAssetUrl);
|
|
expect(revokedAssetResponse.status).toBe(404);
|
|
} finally {
|
|
pendingRenderer = null;
|
|
await writeFile(rendererBlockingFile, png);
|
|
}
|
|
const response = await responsePromise;
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
expect(response.status, body.toString()).toBe(200);
|
|
expect(body).toEqual(png);
|
|
}, 30_000);
|
|
|
|
it('loads relative assets through the desktopArtifactExporter fallback for an exact-project run token', async () => {
|
|
// Given: a bound Workspace project and a daemon with only the supported image exporter fallback.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
}).token;
|
|
const fallbackArtifact = path.join(outputDir, `fallback-${randomUUID()}.png`);
|
|
let fallbackAssetUrl = '';
|
|
let wroteArtifactAfterAssets = false;
|
|
const exporter = async (
|
|
input: DesktopExportArtifactInput,
|
|
): Promise<DesktopExportArtifactResult> => {
|
|
expect(input.html).toBe(boundProjectHtml);
|
|
expect(input.baseHref).not.toBe(legacyBaseHref);
|
|
fallbackAssetUrl = new URL(rendererStylesheetPath, input.baseHref).href;
|
|
const cssResponse = await fetch(fallbackAssetUrl);
|
|
const cssBytes = Buffer.from(await cssResponse.arrayBuffer());
|
|
expect(cssResponse.status, cssBytes.toString()).toBe(200);
|
|
expect(cssBytes).toEqual(rendererCss);
|
|
const imageResponse = await fetch(new URL(rendererImagePath, input.baseHref));
|
|
const imageBytes = Buffer.from(await imageResponse.arrayBuffer());
|
|
expect(imageResponse.status, imageBytes.toString()).toBe(200);
|
|
expect(imageBytes).toEqual(rendererImage);
|
|
await writeFile(fallbackArtifact, png);
|
|
wroteArtifactAfterAssets = true;
|
|
return { ok: true, path: fallbackArtifact, mime: 'image/png' };
|
|
};
|
|
const fallbackDaemon = await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopArtifactExporter: exporter,
|
|
}) as { url: string; shutdown: () => Promise<void> | void };
|
|
|
|
try {
|
|
// When: the run token requests an image and the exporter follows bearerless relative URLs.
|
|
const response = await fetch(`${fallbackDaemon.url}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
|
|
// Then: both exact assets were authorized before the exporter wrote the exact returned PNG.
|
|
expect(response.status, body.toString()).toBe(200);
|
|
expect(wroteArtifactAfterAssets).toBe(true);
|
|
expect(body).toEqual(png);
|
|
const revokedAssetResponse = await fetch(fallbackAssetUrl);
|
|
expect(revokedAssetResponse.status).toBe(404);
|
|
} finally {
|
|
await fallbackDaemon.shutdown();
|
|
await rm(fallbackArtifact, { force: true });
|
|
}
|
|
}, 30_000);
|
|
|
|
it('rejects a valid run token when the requested project differs', async () => {
|
|
// Given: a token bound to the first project and an output for another bound project.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
}).token;
|
|
const outputPath = path.join(outputDir, 'cross-project.png');
|
|
|
|
// When: the wrapper attempts to export the foreign project with that token.
|
|
const result = await runExportCli(foreignProjectId, outputPath, token);
|
|
|
|
// Then: project authority fails before rendering.
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toContain('FORBIDDEN');
|
|
expect(existsSync(outputPath)).toBe(false);
|
|
});
|
|
|
|
it.each(['invalid', 'expired'] as const)(
|
|
'does not downgrade an %s token to headerless unbound access',
|
|
async (kind) => {
|
|
// Given: an unbound project that succeeds headerless and a presented unusable token.
|
|
const token = kind === 'invalid'
|
|
? 'forged-tool-token'
|
|
: toolTokenRegistry.mint({
|
|
projectId: unboundProjectId,
|
|
runId: `run_${randomUUID()}`,
|
|
nowMs: Date.now() - 120_000,
|
|
ttlMs: 60_000,
|
|
}).token;
|
|
const outputPath = path.join(outputDir, `${kind}-token.png`);
|
|
|
|
// When: the wrapper presents that token for the otherwise unbound project.
|
|
const result = await runExportCli(unboundProjectId, outputPath, token);
|
|
|
|
// Then: token validation fails closed instead of exporting headerless.
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toContain(
|
|
kind === 'invalid' ? 'TOOL_TOKEN_INVALID' : 'TOOL_TOKEN_EXPIRED',
|
|
);
|
|
expect(existsSync(outputPath)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('enforces the export capability on otherwise valid project tokens', async () => {
|
|
// Given: an exact-project token restricted to an unrelated media capability.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
allowedEndpoints: ['/api/tools/media/generate'],
|
|
allowedOperations: ['media:generate'],
|
|
}).token;
|
|
const outputPath = path.join(outputDir, 'endpoint-denied.png');
|
|
|
|
// When: the wrapper attempts export through that restricted grant.
|
|
const result = await runExportCli(projectId, outputPath, token);
|
|
|
|
// Then: the endpoint allowlist rejects the request before rendering.
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toContain('TOOL_ENDPOINT_DENIED');
|
|
expect(existsSync(outputPath)).toBe(false);
|
|
});
|
|
|
|
it('does not extend the CLI export capability to inline project reads', async () => {
|
|
// Given: a default run token whose export grant is limited to screenshot exports.
|
|
const token = toolTokenRegistry.mint({
|
|
projectId,
|
|
runId: `run_${randomUUID()}`,
|
|
}).token;
|
|
|
|
// When: the token is presented to the separate inline-HTML export surface.
|
|
const response = await fetch(`${daemonUrl}/api/projects/${projectId}/export/index.html?inline=1`, {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
// Then: endpoint authorization fails before project contents are returned.
|
|
expect(response.status).toBe(403);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: { code: 'TOOL_ENDPOINT_DENIED' },
|
|
});
|
|
});
|
|
|
|
it('treats a forwarded foreign Basic credential as a proxy-authenticated browser request', async () => {
|
|
// Given: a reverse proxy (Coolify/Traefik basic auth) authenticates the
|
|
// browser itself and forwards its own credential to the daemon.
|
|
const proxyCredential = `Basic ${Buffer.from('proxy-user:proxy-pass').toString('base64')}`;
|
|
|
|
// When: the browser export and archive surfaces are reached through it.
|
|
const exportResponse = await fetch(`${daemonUrl}/api/projects/${unboundProjectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { authorization: proxyCredential, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
const exportBody = Buffer.from(await exportResponse.arrayBuffer());
|
|
const archiveResponse = await fetch(`${daemonUrl}/api/projects/${unboundProjectId}/archive`, {
|
|
headers: { authorization: proxyCredential },
|
|
});
|
|
await archiveResponse.arrayBuffer();
|
|
|
|
// Then: the header never claims the run-scoped tool lane, so both stay on
|
|
// project authorization exactly like a header-free browser request.
|
|
expect(exportResponse.status, exportBody.toString()).toBe(200);
|
|
expect(exportBody).toEqual(png);
|
|
expect(archiveResponse.status).toBe(200);
|
|
});
|
|
|
|
it.each([
|
|
['a malformed Bearer credential with no token', 'Bearer '],
|
|
['a bare Bearer scheme', 'Bearer'],
|
|
] as const)('fails closed on %s', async (_label, authorization) => {
|
|
// Given: a Bearer scheme carrying no token. Trimming reduces `Bearer ` to
|
|
// `Bearer`, so scheme detection must not depend on a token following it.
|
|
const request = {
|
|
method: 'POST',
|
|
headers: { authorization, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
} as const;
|
|
|
|
// When: it reaches an export surface that succeeds headerless.
|
|
const response = await fetch(`${daemonUrl}/api/projects/${unboundProjectId}/export/image`, request);
|
|
const body = await response.text();
|
|
|
|
// Then: it stays on the tool-token lane and fails closed, rather than
|
|
// downgrading to the browser project-authority lane.
|
|
expect(response.status, body).toBe(401);
|
|
expect(JSON.parse(body)).toMatchObject({ error: { code: 'TOOL_TOKEN_MISSING' } });
|
|
});
|
|
|
|
it('still routes foreign Bearer credentials to the run-scoped tool-token lane', async () => {
|
|
// Given: a Bearer credential that is neither the daemon API token nor a minted grant.
|
|
const request = {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer forged-tool-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
} as const;
|
|
|
|
// When: it is presented to the same otherwise-headerless export surface.
|
|
const response = await fetch(`${daemonUrl}/api/projects/${unboundProjectId}/export/image`, request);
|
|
|
|
// Then: the tool lane still fails closed instead of downgrading to project auth.
|
|
expect(response.status).toBe(401);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: { code: 'TOOL_TOKEN_INVALID' },
|
|
});
|
|
});
|
|
|
|
async function runExportCli(
|
|
requestedProjectId: string,
|
|
outputPath: string,
|
|
token?: string,
|
|
extraArgs: string[] = [],
|
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
NODE_OPTIONS: '',
|
|
OD_DAEMON_URL: daemonUrl,
|
|
OD_PROJECT_ID: requestedProjectId,
|
|
};
|
|
if (token) env.OD_TOOL_TOKEN = token;
|
|
else delete env.OD_TOOL_TOKEN;
|
|
try {
|
|
const { stdout, stderr } = await execFileP(
|
|
process.execPath,
|
|
[
|
|
tsxCli,
|
|
cliEntry,
|
|
'export',
|
|
'index.html',
|
|
'--project',
|
|
requestedProjectId,
|
|
'--format',
|
|
'image',
|
|
'--out',
|
|
outputPath,
|
|
...extraArgs,
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env,
|
|
timeout: 15_000,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
},
|
|
);
|
|
return { code: 0, stdout, stderr };
|
|
} catch (error) {
|
|
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
|
return {
|
|
code: failure.code ?? 1,
|
|
stdout: failure.stdout ?? '',
|
|
stderr: failure.stderr ?? '',
|
|
};
|
|
}
|
|
}
|
|
});
|