Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# oEmbed Changelog

## 3.2.2 - 2026-05-01

### Fixed

- Pasting an HTML embed code (e.g. from Vimeo or YouTube) instead of a URL no longer breaks the entry edit page (fixes #181). Thanks @tomfischerNL
- HTML embed codes are now rejected in `normalizeValue()` and `OembedService::embed()` — including any bad data already stored in the database — so they are never saved or rendered
- The CP field input value is now properly HTML-escaped, preventing HTML injection from malformed stored values
- A clear validation error ("Please enter a URL, not an HTML embed code.") is now surfaced inline in the Control Panel when an embed code is submitted

### Added

- Unit tests for HTML embed code rejection in `OembedFieldTest` and `OembedServiceTest`

## 3.2.1 - 2026-03-03

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "wrav/oembed",
"description": "A simple plugin to extract media information from websites, like youtube videos, twitter statuses or blog articles.",
"type": "craft-plugin",
"version": "3.2.1",
"version": "3.2.2",
"keywords": [
"craft",
"cms",
Expand Down
72 changes: 64 additions & 8 deletions src/fields/OembedField.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ public function getContentGqlType(): \GraphQL\Type\Definition\Type|array
];
}

/**
* Detect whether a string contains HTML tags (e.g. an embed code pasted instead of a URL).
*/
private function isHtmlEmbedCode(string $value): bool
{
return trim($value) !== strip_tags(trim($value));
}

/**
* Normalize a URL by adding https:// scheme if missing.
*
Expand Down Expand Up @@ -170,7 +178,7 @@ private function normalizeUrl(string $url): string
*/
public function normalizeValue(mixed $value, ?craft\base\ElementInterface $element = null): mixed
{
// If null, dont proceed
// If null, don't proceed
if ($value === null) {
if ($this->required) {
return null;
Expand All @@ -182,18 +190,21 @@ public function normalizeValue(mixed $value, ?craft\base\ElementInterface $eleme
// If an instance of `OembedModel` and URL is set, return it
if ($value instanceof OembedModel && $value->url) {
$normalizedUrl = $this->normalizeUrl($value->url);
if ($this->isHtmlEmbedCode($normalizedUrl)) {
return new OembedModel(null);
}
if (UrlHelper::isFullUrl($normalizedUrl)) {
return $this->value = new OembedModel($normalizedUrl);
} else {
// If we get here, somethings gone wrong
// If we get here, something's gone wrong
return new OembedModel(null);
}
}

// If JSON object string, decode it and use that as the value
$value = Json::decodeIfJson($value); // Returns an array

// If array with `url` attribute, thats our url so update the value
// If array with `url` attribute, that's our url so update the value
// Run `getValue` to avoid https://github.com/wrav/oembed/issues/74
while(is_array($value)) {
$value = ArrayHelper::getValue($value, 'url');
Expand All @@ -204,20 +215,25 @@ public function normalizeValue(mixed $value, ?craft\base\ElementInterface $eleme
$value = $this->normalizeUrl($value);
}

// Reject HTML embed codes — catches raw HTML and any already-prefixed bad data (fixes #181)
if (is_string($value) && $this->isHtmlEmbedCode($value)) {
return new OembedModel(null);
}

// If URL string, return an instance of `OembedModel`
if (is_string($value) && UrlHelper::isFullUrl($value)) {
return $this->value = new OembedModel($value);
}

// If we get here, somethings gone wrong
// If we get here, something's gone wrong
return new OembedModel(null);
}

/**
* Modifies an element query.
*
* @param ElementInterface $query The element query
* @param mixed $value The value that was set on this fields corresponding [[ElementCriteriaModel]]
* @param mixed $value The value that was set on this field's corresponding [[ElementCriteriaModel]]
* param, if any.
* @return null|false `false` in the event that the method is sure that no elements are going to be found.
*/
Expand All @@ -234,9 +250,35 @@ public function getSettingsHtml(): ?string
return null;
}

/**
* @inheritdoc
*/
public function getElementValidationRules(): array
{
$rules = parent::getElementValidationRules();
$handle = $this->handle;

$rules[] = [
$handle,
function($attribute, $params) use ($handle) {
$request = Craft::$app->getRequest();
if (!$request instanceof \craft\web\Request || !$request->getIsPost()) {
return;
}
$fields = $request->getBodyParam('fields', []);
$rawValue = $fields[$handle] ?? null;
if (is_string($rawValue) && $rawValue !== strip_tags($rawValue)) {
$this->addError($attribute, Craft::t('oembed', 'Please enter a URL, not an HTML embed code.'));
}
},
];

return $rules;
}

/**
* @param ElementInterface|null $element The element the field is associated with, if there is one
* @param mixed $value The fields value. This will either be the [[normalizeValue() normalized
* @param mixed $value The field's value. This will either be the [[normalizeValue() normalized
* value]], raw POST data (i.e. if there was a validation error), or null
* @return string The input HTML.
*/
Expand All @@ -246,13 +288,27 @@ public function getInputHtml($value, ElementInterface $element = null): string
$hidden = $settings['previewHidden'];
$previewIcon = $hidden ? 'expand' : 'collapse';

// Safely extract the URL string and detect HTML embed codes (#181)
$urlString = '';
$isHtmlInput = false;
if ($value instanceof OembedModel) {
$urlString = is_string($value->url) ? $value->url : '';
} elseif (is_string($value)) {
if ($this->isHtmlEmbedCode($value)) {
$isHtmlInput = true;
} else {
$urlString = $value;
}
}

$input = '<input name="'.$this->handle.'" class="text nicetext fullwidth oembed-field" value="'.$value.'" />';
$input = '<input name="'.$this->handle.'" class="text nicetext fullwidth oembed-field" value="'.htmlspecialchars($urlString, ENT_QUOTES, 'UTF-8').'" />';
$preview = '<div class="oembed-header">
<p class="fullwidth"><strong>Preview</strong> <span class="right" data-icon-after="'.$previewIcon.'"></span></p>
</div>';

if ($value) {
if ($isHtmlInput) {
$preview .= '<div class="oembed-preview"><p class="error">'.Craft::t('oembed', 'Please enter a URL, not an HTML embed code.').'</p></div>';
} elseif ($value) {
try {
if ($embed = new OembedModel($value)) {
$embed = $embed->embed();
Expand Down
6 changes: 6 additions & 0 deletions src/services/OembedService.php
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,12 @@ public function embed($url, array $options = [], array $cacheProps = [], $factor
$url = $url ?: '';

if (is_string($url)) {
// Reject HTML embed codes before attempting any fetch (fixes #181)
$trimmed = trim($url);
if ($trimmed !== strip_tags($trimmed)) {
return null;
}

$url = $this->normalizeUrl($url);
}

Expand Down
42 changes: 42 additions & 0 deletions tests/unit/fields/OembedFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,46 @@ public function testNormalizeValueWithJsonString()
$this->assertInstanceOf(OembedModel::class, $result);
$this->assertEquals('https://youtube.com/watch?v=dQw4w9WgXcQ', $result->url);
}

/**
* Test that HTML embed codes are rejected and return an empty OembedModel (fixes #181)
* @dataProvider htmlEmbedCodeProvider
*/
public function testNormalizeValueRejectsHtmlEmbedCode(string $input)
{
$result = $this->field->normalizeValue($input, null);

$this->assertInstanceOf(OembedModel::class, $result);
$this->assertEmpty($result->url, 'HTML embed code should produce an empty URL, not be stored or rendered');
}

public static function htmlEmbedCodeProvider(): array
{
return [
'vimeo full embed code' => [
'<div style="padding:56.25% 0 0 0;position:relative;"><iframe src="https://player.vimeo.com/video/1127343747?badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share" referrerpolicy="strict-origin-when-cross-origin" style="position:absolute;top:0;left:0;width:100%;height:100%;" title="Test Video"></iframe></div><script src="https://player.vimeo.com/api/player.js"></script>',
],
'youtube iframe embed' => [
'<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>',
],
'bare iframe tag' => [
'<iframe src="https://example.com/video"></iframe>',
],
'already-prefixed bad data from db' => [
'https://<div style="padding:56.25% 0 0 0"><iframe src="https://player.vimeo.com/video/123"></iframe></div>',
],
];
}

/**
* Test that an OembedModel containing an HTML embed code URL is also rejected
*/
public function testNormalizeValueRejectsOembedModelWithHtmlUrl()
{
$model = new OembedModel('https://<div><iframe src="https://player.vimeo.com/video/123"></iframe></div>');
$result = $this->field->normalizeValue($model, null);

$this->assertInstanceOf(OembedModel::class, $result);
$this->assertEmpty($result->url);
}
}
28 changes: 28 additions & 0 deletions tests/unit/services/OembedServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,34 @@ public function testRenderHandlesEmptyUrl()
$this->assertInstanceOf(\Twig\Markup::class, $result);
}

/**
* Test that HTML embed codes are rejected immediately without attempting a fetch (fixes #181)
* @dataProvider htmlEmbedCodeProvider
*/
public function testEmbedRejectsHtmlEmbedCode(string $input)
{
$result = $this->service->embed($input);
$this->assertNull($result, 'embed() must return null for HTML embed codes, not attempt a fetch');
}

public static function htmlEmbedCodeProvider(): array
{
return [
'vimeo full embed code' => [
'<div style="padding:56.25% 0 0 0;position:relative;"><iframe src="https://player.vimeo.com/video/1127343747?badge=0&autopause=0" frameborder="0" allow="autoplay; fullscreen"></iframe></div><script src="https://player.vimeo.com/api/player.js"></script>',
],
'youtube iframe embed' => [
'<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" frameborder="0" allowfullscreen></iframe>',
],
'bare iframe' => [
'<iframe src="https://example.com/video"></iframe>',
],
'already-prefixed html stored in db' => [
'https://<div><iframe src="https://player.vimeo.com/video/123"></iframe></div>',
],
];
}

/**
* Test broken URL notification system handles empty URLs correctly
*/
Expand Down
Loading