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
10 changes: 5 additions & 5 deletions resources/views/forms/automagic-email.antlers.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,19 +7,19 @@
{{ value }}{{ permalink }}{{ if !last }}, {{ /if }}{{ /value }}

{{ elseif fieldtype == "select" && !config:multiple }}
{{ value:label ?? value }}
{{ (value:label ?? value) | sanitize }}

{{ elseif fieldtype == "radio" }}
{{ value:label ?? value }}
{{ (value:label ?? value) | sanitize }}

{{ elseif fieldtype == "select" || fieldtype == "checkboxes" || fieldtype == "dictionary" }}
{{ value }}{{ label ?? value }}{{ if !last }}, {{ /if }}{{ /value }}
{{ value }}{{ (label ?? value) | sanitize }}{{ if !last }}, {{ /if }}{{ /value }}

{{ elseif value|is_iterable }}
{{ value | json }}
{{ value | json | sanitize }}

{{ else }}
{{ value | nl2br }}
{{ value | sanitize | nl2br }}
{{ /if }}

{{ /if }}
Expand Down
64 changes: 64 additions & 0 deletions tests/Forms/EmailTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,70 @@ public function it_adds_data_to_the_view()
$this->assertEquals('Statamic', (string) $email->viewData['company']['company_name']);
}

#[Test]
public function it_escapes_submitted_values_in_the_automagic_email()
{
$formBlueprint = Blueprint::makeFromFields([
'name' => ['type' => 'text'],
'message' => ['type' => 'textarea'],

// The select/radio/checkboxes branches emit `label ?? value`, and the label
// falls back to the raw value when there's no matching option. The raw value
// is attacker-controlled, so it must be escaped. The option label is author
// controlled (it lives in the blueprint), so it's not really exploitable, but
// we escape it too for consistency. Both situations are asserted below.
'select_labelled' => ['type' => 'select', 'options' => ['a' => '<script>select-label</script>']],
'select_raw' => ['type' => 'select'],
'radio_labelled' => ['type' => 'radio', 'options' => ['b' => '<script>radio-label</script>']],
'radio_raw' => ['type' => 'radio'],
'checkboxes' => ['type' => 'checkboxes', 'options' => ['c' => '<script>checkbox-label</script>']],
]);

BlueprintRepository::shouldReceive('find')->with('forms.test')->andReturn($formBlueprint);

$form = tap(Form::make('test'))->save();

$submission = $form->makeSubmission()->data([
'name' => '<img src=x onerror=alert(1)>',
'message' => "line one\n<script>alert(2)</script>",
'select_labelled' => 'a',
'select_raw' => '"><svg onload=alert(3)>',
'radio_labelled' => 'b',
'radio_raw' => '"><svg onload=alert(4)>',
'checkboxes' => ['c', '<b>raw-checkbox</b>'],
]);

$email = new Email($submission, ['to' => 'test@test.com'], Site::default());

$body = $email->render();

// Attacker-controlled values are escaped, not emitted as live markup.
$assertEscaped = function ($raw, $escaped) use ($body) {
$this->assertStringNotContainsString($raw, $body);
$this->assertStringContainsString($escaped, $body);
};

// text / textarea
$assertEscaped('<img src=x onerror=alert(1)>', '&lt;img src=x onerror=alert(1)&gt;');
$assertEscaped('<script>alert(2)</script>', '&lt;script&gt;alert(2)&lt;/script&gt;');

// select — the author-controlled option label (sanitized for consistency) and
// the attacker-controlled raw fallback value.
$assertEscaped('<script>select-label</script>', '&lt;script&gt;select-label&lt;/script&gt;');
$assertEscaped('<svg onload=alert(3)>', '&lt;svg onload=alert(3)&gt;');

// radio — same, option label and raw fallback value.
$assertEscaped('<script>radio-label</script>', '&lt;script&gt;radio-label&lt;/script&gt;');
$assertEscaped('<svg onload=alert(4)>', '&lt;svg onload=alert(4)&gt;');

// checkboxes — same, option label and raw fallback value.
$assertEscaped('<script>checkbox-label</script>', '&lt;script&gt;checkbox-label&lt;/script&gt;');
$assertEscaped('<b>raw-checkbox</b>', '&lt;b&gt;raw-checkbox&lt;/b&gt;');

// Legitimate multiline text still renders line breaks.
$this->assertStringContainsString('line one<br', $body);
}

#[Test]
public function attachments_are_added()
{
Expand Down
Loading