fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors

Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 14:26:18 +02:00
co-authored by Claude Opus 5.5
parent 4b72c2bb42
commit 8688a02402
13 changed files with 278 additions and 104 deletions
@@ -186,7 +186,7 @@ final class ShortformWriterTest extends TestCase
'color: two references' => ['color', $color . $color],
// Markers the sanitizer would rebuild (S28).
'text: tag-split marker' => ['text', '$variable<x>(' . substr($color, 10)],
'text: NUL-split marker' => ['text', '$variable' . chr(0) . '(' . substr($color, 10)],
'text: NUL-split marker embedded' => ['text', 'Hallo $variable' . chr(0) . '(' . substr($color, 10)],
'text: octet-split marker' => ['text', '$variable%41(' . substr($color, 10)],
'url: stripped-characters marker' => ['url', '$variable<"{}>(y)$'],
'url: NUL-split marker' => ['url', '$variable' . chr(0) . '(y)$'],
@@ -220,17 +220,17 @@ final class ShortformWriterTest extends TestCase
self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1]));
}
// S32: like sanitize_text_field, the fake text() keeps NUL – so the marker the NUL row
// hides is rebuilt only by AttrWriter's control-character stripping (production code).
self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker'][1]));
self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker'][1]));
// S32/S32a: like sanitize_text_field, the fake text() keeps NUL – the marker the NUL row
// hides appears only through AttrWriter's control stripping on the raw value (production code).
self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker embedded'][1]));
self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker embedded'][1]));
foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1]));
}
self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]);
self::assertStringContainsString(chr(0), $rows['text: NUL-split marker embedded'][1]);
}
/** Ruling S33b: an attachment alt carrying a reference (whole or split) is dropped, never canonicalized. */
@@ -245,6 +245,21 @@ final class ShortformWriterTest extends TestCase
}
}
/** Ruling S32a: a control character after "<" never turns into a tag – in text and in media alt. */
public function testControlSplitTagsAreRemoved(): void
{
$writer = ComposeFixture::shortform();
foreach (['<' . chr(11) . 'img src=x onerror=alert(1)>Hallo', '<' . chr(12) . 'script>alert(1)</script>Hallo'] as $value) {
$result = $writer->apply('heading', [], ['text' => $value], 'n');
self::assertSame([], $result['errors']);
self::assertSame('Hallo', $result['attrs']['title']['innerContent']['desktop']['value']);
}
$image = $writer->apply('image', [], ['image_id' => 46], 'n');
self::assertSame([], $image['errors']);
self::assertSame('Team', $image['attrs']['image']['innerContent']['desktop']['value']['alt']);
}
/** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */
public function testReferenceSettingsInTextAreCleaned(): void
{