From f004595e50607a2ace27b4c8ec09d2d33e02d6a3 Mon Sep 17 00:00:00 2001 From: devjerry0 <99199491+devjerry0@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:01:51 -0400 Subject: [PATCH] test: verify link preview safety checks with mutations Optional selected mutation runner and CI/scripts. Existing unit/feature tests remain with each implementation layer. Codex-Thread: 01a15b08-d4ad-7e72-8725-b3ea1fde71ce --- .github/workflows/tests.yml | 3 + composer.json | 6 +- package.json | 3 +- tests/Mutation/GifSecurity.test.ts | 145 +++++++++++++++++++ tests/Mutation/php-gif-mutations.ts | 199 +++++++++++++++++++++++++++ tests/Mutation/playback-mutations.ts | 95 +++++++++++++ tests/tsconfig.json | 3 +- 7 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 tests/Mutation/GifSecurity.test.ts create mode 100644 tests/Mutation/php-gif-mutations.ts create mode 100644 tests/Mutation/playback-mutations.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d2a1d9c1..4fe0759e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,5 +146,8 @@ jobs: if: steps.build-cache.outputs.cache-hit != 'true' run: bun run build + - name: GIF Security Mutations + run: composer test:mutations + - name: Tests run: composer test:unit diff --git a/composer.json b/composer.json index a1b2c2a36..39ed54b18 100644 --- a/composer.json +++ b/composer.json @@ -116,8 +116,10 @@ "@test:lint", "@test:types", "@test:type-coverage", - "@test:unit" - ] + "@test:unit", + "@test:mutations" + ], + "test:mutations": "bun run test:mutations" }, "extra": { "laravel": { diff --git a/package.json b/package.json index 0fed34885..d0cfff9b0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts" + "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts", + "test:mutations": "node --experimental-strip-types --test tests/Mutation/*.test.ts" }, "dependencies": { "@daypicker/react": "^10.0.2", diff --git a/tests/Mutation/GifSecurity.test.ts b/tests/Mutation/GifSecurity.test.ts new file mode 100644 index 000000000..464bae595 --- /dev/null +++ b/tests/Mutation/GifSecurity.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import type { SpawnSyncReturns } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { phpGifMutations } from './php-gif-mutations.ts'; +import type { GifMutation } from './php-gif-mutations.ts'; +import { playbackMutations } from './playback-mutations.ts'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const temporary = mkdtempSync(join(tmpdir(), 'devnull-gif-mutations-')); +const mutationTarget = 'PEST_MUTATION_TESTING'; +const mutationSource = 'PEST_MUTATION_FILE'; +const timeoutMilliseconds = 60_000; +const controls = new Set(); + +function runPhp( + mutation: GifMutation, + source: string, +): SpawnSyncReturns { + const modifiedFile = join(temporary, 'mutated.php'); + writeFileSync(modifiedFile, source); + + return spawnSync( + 'php', + [ + 'vendor/bin/pest', + '--ci', + '--compact', + '--colors=never', + mutation.test, + ], + { + cwd: root, + encoding: 'utf8', + timeout: timeoutMilliseconds, + env: { + ...process.env, + [mutationTarget]: join(root, mutation.source), + [mutationSource]: modifiedFile, + }, + }, + ); +} + +function runPlayback( + mutation: GifMutation, + source: string, +): SpawnSyncReturns { + const modifiedFile = join(temporary, mutation.source); + const testFile = join(temporary, mutation.test); + mkdirSync(dirname(modifiedFile), { recursive: true }); + mkdirSync(dirname(testFile), { recursive: true }); + writeFileSync(join(temporary, 'package.json'), '{"type":"module"}'); + writeFileSync(modifiedFile, source); + writeFileSync(testFile, readFileSync(join(root, mutation.test))); + const environment = { ...process.env }; + delete environment.NODE_TEST_CONTEXT; + + return spawnSync( + process.execPath, + ['--experimental-strip-types', '--test', testFile], + { + encoding: 'utf8', + timeout: timeoutMilliseconds, + env: environment, + }, + ); +} + +function output(result: SpawnSyncReturns): string { + assert.ifError(result.error); + assert.notEqual(result.status, null, 'Mutation process did not finish'); + + return result.stdout + result.stderr; +} + +type Runner = ( + mutation: GifMutation, + source: string, +) => SpawnSyncReturns; + +function verifyControl( + mutation: GifMutation, + source: string, + run: Runner, +): void { + if (controls.has(mutation.source)) { + return; + } + + const control = run(mutation, source); + const report = output(control); + assert.equal(control.status, 0, report); + assert.match( + report, + run === runPhp ? /Tests:\s+.*\bpassed\b/ : /\bpass [1-9]\d*/, + report, + ); + controls.add(mutation.source); +} + +function checkMutation(mutation: GifMutation): void { + const original = readFileSync(join(root, mutation.source), 'utf8'); + const run = mutation.source.endsWith('.php') ? runPhp : runPlayback; + verifyControl(mutation, original, run); + assert.ok( + original.includes(mutation.before), + `Mutation no longer matches ${mutation.source}`, + ); + const result = run( + mutation, + original.replace(mutation.before, mutation.after), + ); + const report = output(result); + assert.notEqual( + result.status, + 0, + `Mutation survived: ${mutation.name}\n${report}`, + ); + assert.match( + report, + run === runPhp ? /Tests:\s+.*\bfailed\b/ : /ERR_ASSERTION/, + report, + ); +} + +await test('GIF security mutation gate', async (context) => { + context.after(() => rmSync(temporary, { recursive: true, force: true })); + + for (const mutation of [...phpGifMutations, ...playbackMutations]) { + await context.test(`detects mutation: ${mutation.name}`, () => + checkMutation(mutation), + ); + } +}); diff --git a/tests/Mutation/php-gif-mutations.ts b/tests/Mutation/php-gif-mutations.ts new file mode 100644 index 000000000..a254509f7 --- /dev/null +++ b/tests/Mutation/php-gif-mutations.ts @@ -0,0 +1,199 @@ +export type GifMutation = { + name: string; + source: string; + test: string; + before: string; + after: string; +}; + +export const phpGifMutations: GifMutation[] = [ + { + name: 'concurrent workers must serialize an unfurl', + source: 'app/Jobs/UnfurlLink.php', + test: 'tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php', + before: '->lock(self::METADATA_LOCK)', + after: '', + }, + { + name: 'unfurl workers must allow foreign-key references', + source: 'app/Jobs/UnfurlLink.php', + test: 'tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php', + before: '->lock(self::METADATA_LOCK)', + after: '->lockForUpdate()', + }, + { + name: 'publication retries stay bounded', + source: 'app/Models/LinkPreview.php', + test: 'tests/Unit/Actions/CreateCommitTest.php', + before: " ->where('publication_unfurl_retries', '<', self::MAX_PUBLICATION_UNFURL_RETRIES)\n", + after: '', + }, + { + name: 'definitive unfurl outcomes remain cached', + source: 'app/Models/LinkPreview.php', + test: 'tests/Unit/Actions/CreateCommitTest.php', + before: " ->whereNotNull('unfurl_failed_at')\n", + after: '', + }, + { + name: 'redirect limit cannot increase', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_REDIRECTS = 3', + after: 'MAX_REDIRECTS = 4', + }, + { + name: 'redirect limit cannot decrease', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_REDIRECTS = 3', + after: 'MAX_REDIRECTS = 2', + }, + { + name: 'redirect cycles stop before refetching', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if (isset($visited[$source]))', + after: 'if (false)', + }, + { + name: 'non-default ports are rejected', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if ($url->getPort() !== null)', + after: 'if (false)', + }, + { + name: 'non-global addresses are rejected', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false', + after: 'return true', + }, + { + name: 'Symfony special-use subnets stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Services/LinkPreviewAddressTest.php', + before: '...IpUtils::PRIVATE_SUBNETS,', + after: '', + }, + { + name: 'additional relay ranges stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: "'192.88.99.0/24',", + after: '', + }, + { + name: 'all DNS answers must be public', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'array_all($addresses, $this->isPublic(...))', + after: 'true', + }, + { + name: 'NAT64 translations stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Services/LinkPreviewAddressTest.php', + before: "'64:ff9b::/32',", + after: '', + }, + { + name: 'response body is capped', + source: 'app/Services/LimitedResponseBody.php', + test: 'tests/Unit/Services/LimitedResponseBodyTest.php', + before: '$remaining = max(0, $this->maxBytes - $this->tell());', + after: '$remaining = PHP_INT_MAX;', + }, + { + name: 'seeking cannot bypass the byte cap', + source: 'app/Services/LimitedResponseBody.php', + test: 'tests/Unit/Services/LimitedResponseBodyTest.php', + before: 'max(0, $this->maxBytes - $this->tell())', + after: 'max(-1, $this->maxBytes - $this->tell())', + }, + { + name: 'overall deadline stays at five seconds', + source: 'app/Services/LinkPreviewBudget.php', + test: 'tests/Unit/Services/LinkPreviewBudgetTest.php', + before: 'TIMEOUT_SECONDS = 5', + after: 'TIMEOUT_SECONDS = 6', + }, + { + name: 'remaining timeout never rounds upward', + source: 'app/Services/LinkPreviewBudget.php', + test: 'tests/Unit/Services/LinkPreviewBudgetTest.php', + before: 'floor(($deadline - $this->seconds())', + after: 'round(($deadline - $this->seconds())', + }, + { + name: 'automatic HTTP redirects stay disabled', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: '->withoutRedirecting()', + after: '->withOptions([RequestOptions::ALLOW_REDIRECTS => true])', + }, + { + name: 'automatic decompression stays disabled', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'RequestOptions::DECODE_CONTENT => false', + after: 'RequestOptions::DECODE_CONTENT => true', + }, + { + name: 'requests pin the checked address', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'CURLOPT_CONNECT_TO => [$connection]', + after: 'CURLOPT_CONNECT_TO => []', + }, + { + name: 'content type is checked', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if ($mediaType !== self::CONTENT_TYPE)', + after: 'if (false)', + }, + { + name: 'both supported GIF signatures remain valid', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: "SIGNATURES = ['GIF87a', 'GIF89a']", + after: "SIGNATURES = ['GIF89a']", + }, + { + name: 'truncated GIF descriptors are rejected', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MIN_HEADER_BYTES = 13', + after: 'MIN_HEADER_BYTES = 12', + }, + { + name: 'GIF dimensions must be nonzero', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION;', + after: 'return true;', + }, + { + name: 'oversized URLs stay rejected', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH', + after: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH + 1', + }, + { + name: 'maximum-length URLs remain supported', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH', + after: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH - 1', + }, + { + name: 'Giphy hosts cannot be spoofed', + source: 'app/Services/EmbedProviders/Giphy.php', + test: 'tests/Unit/Services/EmbedProviders/GiphyTest.php', + before: 'if (! in_array($url->getAsciiHost(), self::SUPPORTED_HOSTS, true))', + after: 'if (false)', + }, +]; diff --git a/tests/Mutation/playback-mutations.ts b/tests/Mutation/playback-mutations.ts new file mode 100644 index 000000000..6cf818f17 --- /dev/null +++ b/tests/Mutation/playback-mutations.ts @@ -0,0 +1,95 @@ +import type { GifMutation } from './php-gif-mutations.ts'; + +export const playbackMutations: GifMutation[] = [ + { + name: 'play unsafe verified GIF sources', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'parseHttpsUrl(source) === null', + after: 'false', + }, + { + name: 'ignore reduced motion', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'return !reducedMotion;', + after: 'return true;', + }, + { + name: 'ignore visibility', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' !isVisible ||\n', + after: '', + }, + { + name: 'ignore static cards', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' isStatic ||\n', + after: '', + }, + { + name: 'ignore failed loads', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' hasFailed ||\n', + after: '', + }, + { + name: 'ignore Stop', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' playback === GIF_PLAYBACK_MODE.STOPPED', + after: ' false', + }, + { + name: 'deny explicit Play', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'playback === GIF_PLAYBACK_MODE.PLAYING', + after: 'false', + }, + { + name: 'keep GIF failures after a manual retry', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'failedSource: null,\n retryCount: state.retryCount + 1', + after: 'failedSource: state.failedSource,\n retryCount: state.retryCount + 1', + }, + { + name: 'allow unlimited GIF retries', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'state.retryCount < MAX_GIF_RETRIES', + after: 'true', + }, + { + name: 'allow HTTP', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.protocol !== 'https:' ||\n", + after: '', + }, + { + name: 'allow username', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.username !== '' ||\n", + after: '', + }, + { + name: 'allow password', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.password !== '' ||\n", + after: '', + }, + { + name: 'allow non-default port', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.password !== '' ||\n url.port !== ''", + after: " url.password !== ''", + }, +]; diff --git a/tests/tsconfig.json b/tests/tsconfig.json index cc08a6326..4b6e53a1e 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -41,6 +41,7 @@ "Unit/RemotePreferences.test.tsx", "Unit/ChatNavigation.test.tsx", "../resources/js/types/*.d.ts", - "draft-images.config.ts" + "draft-images.config.ts", + "Mutation/**/*.ts" ] }