Commit efd8ceea authored by Oleksandr Kuzava's avatar Oleksandr Kuzava
Browse files

Issue #185253: Drop serialized inline block data from imported layouts

parent 4f42b446
Loading
Loading
Loading
Loading
Loading
+5 −1
Changes for src/Plugin/SingleContentSyncFieldProcessor/LayoutSection.php: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -232,11 +232,15 @@ class LayoutSection extends SingleContentSyncFieldProcessorPluginBase implements
      foreach ($section_components as $component) {
        if ($component->getPlugin() instanceof InlineBlock) {
          $configuration = $component->toArray()['configuration'];
          // Imported inline blocks use structured 'blocks' data and revision
          // references. Never pass a serialized object graph from an import
          // to core's InlineBlock plugin.
          unset($configuration['block_serialized']);
          if (isset($configuration['block_revision_id']) && isset($imported_blocks[$configuration['block_revision_id']])) {
            // Replace the old revision id with a new revision id.
            $configuration['block_revision_id'] = $imported_blocks[$configuration['block_revision_id']];
            $component->setConfiguration($configuration);
          }
          $component->setConfiguration($configuration);
        }
      }
    }
+29 −0
Changes for tests/src/Kernel/Fixtures/SerializedBlockCanary.php: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\single_content_sync\Kernel\Fixtures;

/**
 * Test-only object used to detect an unrestricted unserialize().
 *
 * When an instance of this class is unserialized, __wakeup() flips a static
 * flag. The tests serialize one of these, place it where a block object would
 * live, and assert whether it is (or is not) reconstructed downstream. The
 * class itself is inert; it stands in for a real gadget object.
 */
class SerializedBlockCanary {

  /**
   * Set to TRUE the moment an instance is unserialized.
   */
  public static bool $reconstructed = FALSE;

  /**
   * Records that an instance was rebuilt from serialized bytes.
   */
  public function __wakeup(): void {
    self::$reconstructed = TRUE;
  }

}
+345 −0
Changes for tests/src/Kernel/LayoutSectionImportTest.php: 345 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\single_content_sync\Kernel;

use Drupal\block_content\Entity\BlockContent;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\KernelTests\KernelTestBase;
use Drupal\layout_builder\Plugin\Block\InlineBlock;
use Drupal\layout_builder\Section;
use Drupal\layout_builder\SectionComponent;
use Drupal\node\Entity\Node;
use Drupal\single_content_sync\Plugin\SingleContentSyncFieldProcessor\LayoutSection;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\single_content_sync\Kernel\Fixtures\SerializedBlockCanary;

/**
 * Covers how the layout_section import handles inline block configuration.
 *
 * The importer rebuilds each Layout Builder component from the (fully
 * attacker-controlled) import payload via Section::fromArray(). Core's
 * InlineBlock later reads the 'block_serialized' key back with a bare
 * unserialize(), so that key must not survive an import, while the legitimate
 * inline-block reference must round-trip intact.
 *
 * @group single_content_sync
 * @coversDefaultClass \Drupal\single_content_sync\Plugin\SingleContentSyncFieldProcessor\LayoutSection
 * @runTestsInSeparateProcesses
 * @preserveGlobalState disabled
 */
class LayoutSectionImportTest extends KernelTestBase {

  use ContentTypeCreationTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'single_content_sync',
    'field',
    'file',
    'node',
    'system',
    'text',
    'user',
    'block_content',
    'layout_discovery',
    'layout_builder',
  ];

  /**
   * A serialized stand-in object placed where a block object would be.
   */
  protected string $serializedPayload;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->installEntitySchema('node');
    $this->installEntitySchema('user');
    $this->installEntitySchema('block_content');
    $this->installSchema('layout_builder', ['inline_block_usage']);
    $this->installSchema('node', ['node_access']);
    $this->installConfig(['node', 'system']);

    $this->createContentType(['type' => 'article']);

    // The 'basic' bundle makes core's 'inline_block:basic' plugin available,
    // which is the plugin that owns the unserialize() sink under test.
    BlockContentType::create(['id' => 'basic', 'label' => 'Basic'])->save();

    // Enable Layout Builder overrides on the article view display; this is
    // what creates the layout_builder__layout field the import writes to.
    \Drupal::service('entity_display.repository')
      ->getViewDisplay('node', 'article', 'default')
      ->enableLayoutBuilder()
      ->setOverridable()
      ->save();

    SerializedBlockCanary::$reconstructed = FALSE;
    $this->serializedPayload = serialize(new SerializedBlockCanary());
  }

  /**
   * A crafted block_serialized is neutralized; the core sink becomes inert.
   *
   * Both current and legacy layouts must discard the serialized data before
   * reaching core's save or read paths. This also works with hardened core.
   *
   * @covers ::importFieldValue
   * @dataProvider sectionFormats
   */
  public function testCraftedSerializedBlockIsNeutralized(string $format): void {
    $uuid = '10000000-0000-1000-a000-000000000000';
    $component = new SectionComponent($uuid, 'content', [
      'id' => 'inline_block:basic',
      'label' => 'x',
      'label_display' => '0',
      'provider' => 'layout_builder',
      'view_mode' => 'full',
      'block_serialized' => $this->serializedPayload,
    ]);
    $section = new Section('layout_onecol', [], [$component]);

    // No 'blocks' behind the section: the inline block was never really
    // exported, it is a raw serialized payload smuggled in the section data.
    $value = [
      'blocks' => [],
      'sections' => $this->encodeSections([$section], $format),
    ];

    $node = Node::create(['type' => 'article', 'title' => 'Target']);
    $this->processor()->importFieldValue($node, 'layout_builder__layout', $value);

    $configuration = $this->componentConfiguration($node, $uuid);
    $this->assertArrayNotHasKey(
      'block_serialized',
      $configuration,
      'The imported layout must not carry a serialized block object.'
    );

    // Exercise the presave path before checking core's read path.
    $node->save();
    $this->container->get('entity_type.manager')->getStorage('node')->resetCache();
    $node = Node::load($node->id());
    $configuration = $this->componentConfiguration($node, $uuid);
    $this->assertEmpty($configuration['block_serialized'] ?? NULL);
    $this->driveCoreSink($configuration);
    $this->assertFalse(
      SerializedBlockCanary::$reconstructed,
      'After import the core sink reconstructs nothing.'
    );
  }

  /**
   * A real inline block round-trips through export/import after the strip.
   *
   * A legitimately exported inline block travels as an entry in the 'blocks'
   * list and is referenced from the section by block_revision_id. Dropping
   * block_serialized on import must leave that path intact: the block content
   * survives and the component still points at it.
   *
   * @covers ::exportFieldValue
   * @covers ::importFieldValue
   * @dataProvider sectionFormats
   */
  public function testInlineBlockRoundTripSurvivesStrip(string $format): void {
    $block = BlockContent::create([
      'type' => 'basic',
      'info' => 'ROUNDTRIP-9931',
    ]);
    $block->save();

    // The persisted state of an inline block: referenced by revision id, with
    // no serialized blob.
    $uuid = '20000000-0000-1000-a000-000000000000';
    $component = new SectionComponent($uuid, 'content', [
      'id' => 'inline_block:basic',
      'label' => 'x',
      'label_display' => '0',
      'provider' => 'layout_builder',
      'view_mode' => 'full',
      'block_revision_id' => $block->getRevisionId(),
    ]);
    $section = new Section('layout_onecol', [], [$component]);

    $source = Node::create(['type' => 'article', 'title' => 'Source']);
    $source->set('layout_builder__layout', [['section' => $section]]);
    $source->save();

    // Export through the module, exactly as the real feature does. A
    // legitimate inline block travels as an entry in the 'blocks' list and is
    // referenced from the section by block_revision_id; its block_serialized
    // slot is empty. (Only a crafted import supplies a non-empty blob there.)
    $exported = $this->processor()->exportFieldValue($source->get('layout_builder__layout'));
    $this->assertCount(1, $exported['blocks'], 'The inline block is exported as an entity.');
    $exported_config = $exported['sections'][0]['components'][$uuid]['configuration'];
    $this->assertNotEmpty(
      $exported_config['block_revision_id'],
      'The exported inline block is referenced by revision id.'
    );
    $this->assertEmpty(
      $exported_config['block_serialized'] ?? NULL,
      'A legitimate export carries no serialized block blob.'
    );

    // Require remapping of a revision ID from another site. A malicious blob
    // alongside a legitimate block must not override its structured content.
    $old_revision_id = 999999;
    $exported['blocks'][0]['base_fields']['block_revision_id'] = $old_revision_id;
    $exported['sections'][0]['components'][$uuid]['configuration']['block_revision_id'] = $old_revision_id;
    $exported['sections'][0]['components'][$uuid]['configuration']['block_serialized'] = $this->serializedPayload;
    $sections = array_map([Section::class, 'fromArray'], $exported['sections']);
    $exported['sections'] = $this->encodeSections($sections, $format);

    // Import into a fresh node.
    $target = Node::create(['type' => 'article', 'title' => 'Target']);
    $this->processor()->importFieldValue($target, 'layout_builder__layout', $exported);
    $configuration = $this->componentConfiguration($target, $uuid);
    $this->assertArrayNotHasKey('block_serialized', $configuration);
    $this->assertNotEquals($old_revision_id, $configuration['block_revision_id']);
    $target->save();

    \Drupal::entityTypeManager()->getStorage('node')->resetCache();
    $target = Node::load($target->id());

    // After the node is saved, core's own inline-block handling re-adds an
    // empty block_serialized slot; the invariant is that it is never a blob.
    $configuration = $this->componentConfiguration($target, $uuid);
    $this->assertEmpty(
      $configuration['block_serialized'] ?? NULL,
      'The round-tripped inline block carries no serialized blob.'
    );
    $this->assertNotEmpty(
      $configuration['block_revision_id'],
      'The imported component still references an inline block revision.'
    );

    // The referenced revision resolves to the round-tripped block content.
    $imported_block = \Drupal::entityTypeManager()
      ->getStorage('block_content')
      ->loadRevision($configuration['block_revision_id']);
    $this->assertNotNull($imported_block, 'The referenced block revision exists.');
    $this->assertEquals('ROUNDTRIP-9931', $imported_block->get('info')->value);
    $this->assertFalse(SerializedBlockCanary::$reconstructed);
  }

  /**
   * The complete import service sanitizes layouts on creation and update.
   *
   * @covers ::importFieldValue
   * @dataProvider sectionFormats
   */
  public function testContentImportRemovesSerializedBlock(string $format): void {
    $uuid = $this->container->get('uuid')->generate();
    $component_uuid = $this->container->get('uuid')->generate();
    $section = new Section('layout_onecol', [], [
      new SectionComponent($component_uuid, 'content', [
        'id' => 'inline_block:basic',
        'block_serialized' => $this->serializedPayload,
      ]),
    ]);
    $content = [
      'entity_type' => 'node',
      'bundle' => 'article',
      'uuid' => $uuid,
      'base_fields' => [
        'title' => 'Imported layout',
        'langcode' => 'en',
        'created' => 123456789,
        'status' => TRUE,
      ],
      'custom_fields' => [
        'layout_builder__layout' => [
          'blocks' => [],
          'sections' => $this->encodeSections([$section], $format),
        ],
      ],
    ];

    $node_id = NULL;
    foreach (['Create', 'Update'] as $operation) {
      $content['base_fields']['title'] = $operation;
      $node = $this->container->get('single_content_sync.importer')->doImport($content);
      if ($node_id !== NULL) {
        $this->assertEquals($node_id, $node->id());
      }
      $node_id = $node->id();
      $this->container->get('entity_type.manager')->getStorage('node')->resetCache();
      $node = Node::load($node_id);
      $this->assertEquals($operation, $node->label());
      $this->assertEmpty($this->componentConfiguration($node, $component_uuid)['block_serialized'] ?? NULL);
      $this->assertFalse(SerializedBlockCanary::$reconstructed);
    }
  }

  /**
   * Provides the supported layout export formats.
   */
  public static function sectionFormats(): array {
    return [
      'structured arrays' => ['array'],
      'legacy serialized sections' => ['legacy'],
    ];
  }

  /**
   * Encodes sections using the current or legacy export format.
   */
  protected function encodeSections(array $sections, string $format) {
    return $format === 'legacy'
      ? base64_encode(implode('|', array_map('serialize', $sections)))
      : array_map(static function (Section $section): array {
        return $section->toArray();
      }, $sections);
  }

  /**
   * Returns the layout_section field processor under test.
   */
  protected function processor(): LayoutSection {
    return $this->container
      ->get('plugin.manager.single_content_sync_field_processor')
      ->createInstance('layout_section');
  }

  /**
   * Returns the stored configuration of the first component of a node layout.
   */
  protected function componentConfiguration(Node $node, string $uuid): array {
    $sections = $node->get('layout_builder__layout')->getSections();
    $this->assertCount(1, $sections);
    $component = $sections[0]->getComponent($uuid);
    $this->assertNotNull($component);
    return $component->get('configuration');
  }

  /**
   * Feeds a component configuration to core's InlineBlock unserialize sink.
   *
   * The getEntity() method reads serialized block data; saveBlockContent()
   * reads it during presave. Drive the protected method directly to check
   * the read path without depending on a full render pipeline.
   */
  protected function driveCoreSink(array $configuration): void {
    $configuration += [
      'id' => 'inline_block:basic',
      'label' => 'x',
      'label_display' => '0',
      'provider' => 'layout_builder',
      'view_mode' => 'full',
    ];
    /** @var \Drupal\layout_builder\Plugin\Block\InlineBlock $plugin */
    $plugin = $this->container->get('plugin.manager.block')
      ->createInstance('inline_block:basic', $configuration);
    $this->assertInstanceOf(InlineBlock::class, $plugin);
    $method = new \ReflectionMethod($plugin, 'getEntity');
    $this->assertInstanceOf(BlockContent::class, $method->invoke($plugin));
  }

}