fix: reject linked social preview outputs
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/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 { dirname, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
@@ -30,6 +32,46 @@ const canonicalizeForWrite = async (path) => {
|
|||||||
|
|
||||||
const isInside = (path, root) => path === root || path.startsWith(`${root}${sep}`);
|
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) => {
|
export const generateSocialPreview = async (outputPath) => {
|
||||||
if (typeof outputPath !== 'string' || outputPath.trim() === '') fail();
|
if (typeof outputPath !== 'string' || outputPath.trim() === '') fail();
|
||||||
const output = await canonicalizeForWrite(outputPath);
|
const output = await canonicalizeForWrite(outputPath);
|
||||||
@@ -53,11 +95,12 @@ export const generateSocialPreview = async (outputPath) => {
|
|||||||
</svg>
|
</svg>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await mkdir(dirname(output), { recursive: true });
|
const bytes = await sharp(svg, { density: 72 })
|
||||||
await sharp(svg, { density: 72 })
|
|
||||||
.flatten({ background: '#070910' })
|
.flatten({ background: '#070910' })
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
||||||
.toFile(output);
|
.toBuffer();
|
||||||
|
await mkdir(dirname(output), { recursive: true });
|
||||||
|
await writeRegularFileWithoutFollowingLinks(output, bytes);
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -320,6 +320,55 @@ describe('deterministic asset preparation', () => {
|
|||||||
.rejects.toThrow(/SOCIAL-PREVIEW-PATH-001/u);
|
.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 () => {
|
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
|
||||||
const root = await createRoot();
|
const root = await createRoot();
|
||||||
const inputs = await makeInputs(root, baseRecipe());
|
const inputs = await makeInputs(root, baseRecipe());
|
||||||
|
|||||||
Reference in New Issue
Block a user