fix: reject linked social preview outputs

This commit is contained in:
2026-09-04 23:20:20 +03:00
parent c60477a475
commit 4735ceffe5
2 changed files with 96 additions and 4 deletions

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { mkdir, realpath } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import { mkdir, open, realpath, rename, rm } from 'node:fs/promises';
import { dirname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
@@ -30,6 +32,46 @@ const canonicalizeForWrite = async (path) => {
const isInside = (path, root) => path === root || path.startsWith(`${root}${sep}`);
const writeRegularFileWithoutFollowingLinks = async (output, bytes) => {
if (typeof constants.O_NOFOLLOW !== 'number') fail();
const destinationFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW
| (constants.O_NONBLOCK ?? 0);
let destination;
try {
destination = await open(output, destinationFlags, 0o600);
const stats = await destination.stat();
if (!stats.isFile() || stats.nlink !== 1) fail();
} catch {
fail();
} finally {
await destination?.close();
}
const temporaryPath = `${output}.${randomUUID()}.tmp`;
let temporary;
let installed = false;
try {
temporary = await open(
temporaryPath,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
const stats = await temporary.stat();
if (!stats.isFile() || stats.nlink !== 1) fail();
await temporary.writeFile(bytes);
await temporary.sync();
await temporary.close();
temporary = undefined;
await rename(temporaryPath, output);
installed = true;
} catch {
fail();
} finally {
await temporary?.close();
if (!installed) await rm(temporaryPath, { force: true });
}
};
export const generateSocialPreview = async (outputPath) => {
if (typeof outputPath !== 'string' || outputPath.trim() === '') fail();
const output = await canonicalizeForWrite(outputPath);
@@ -53,11 +95,12 @@ export const generateSocialPreview = async (outputPath) => {
</svg>
`);
await mkdir(dirname(output), { recursive: true });
await sharp(svg, { density: 72 })
const bytes = await sharp(svg, { density: 72 })
.flatten({ background: '#070910' })
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
.toFile(output);
.toBuffer();
await mkdir(dirname(output), { recursive: true });
await writeRegularFileWithoutFollowingLinks(output, bytes);
return output;
};

View File

@@ -320,6 +320,55 @@ describe('deterministic asset preparation', () => {
.rejects.toThrow(/SOCIAL-PREVIEW-PATH-001/u);
});
it('rejects a leaf symlink without changing its out-of-root target', async () => {
const root = await createRoot();
const outsideRoot = await mkdtemp(join(process.cwd(), '.portfolio-social-preview-test-'));
const targetPath = join(outsideRoot, 'protected-target.png');
const outputPath = join(root, 'social-preview.png');
const originalTarget = Buffer.from('outside target must remain unchanged');
let error: unknown;
let targetHashBefore = '';
let targetHashAfter = '';
try {
await writeFile(targetPath, originalTarget);
targetHashBefore = await hashFile(targetPath);
await symlink(targetPath, outputPath);
try {
await generateSocialPreview(outputPath);
} catch (caught) {
error = caught;
}
targetHashAfter = await hashFile(targetPath);
} finally {
await rm(outsideRoot, { recursive: true, force: true });
}
expect.soft(error).toBeInstanceOf(Error);
expect.soft((error as Error | undefined)?.message).toMatch(/SOCIAL-PREVIEW-PATH-001/u);
expect(targetHashAfter).toBe(targetHashBefore);
});
it('rejects a hard-linked destination without changing the linked file', async () => {
const root = await createRoot();
const targetPath = join(root, 'protected-target.png');
const outputPath = join(root, 'social-preview.png');
const originalTarget = Buffer.from('hard-linked target must remain unchanged');
await writeFile(targetPath, originalTarget);
const targetHashBefore = await hashFile(targetPath);
await link(targetPath, outputPath);
let error: unknown;
try {
await generateSocialPreview(outputPath);
} catch (caught) {
error = caught;
}
expect.soft(error).toBeInstanceOf(Error);
expect.soft((error as Error | undefined)?.message).toMatch(/SOCIAL-PREVIEW-PATH-001/u);
expect(await hashFile(targetPath)).toBe(targetHashBefore);
});
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
const root = await createRoot();
const inputs = await makeInputs(root, baseRecipe());