mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
fix: bbcode param parser - prevent xss
This commit is contained in:
@@ -59,13 +59,13 @@ class BBCodeService {
|
||||
|
||||
$escapedContent = preg_replace_callback(
|
||||
$pattern,
|
||||
function ($matches) use ($replacement, $params, &$protectedContent, &$placeholderIndex) {
|
||||
function ($matches) use ($replacement, $params, $tag, &$protectedContent, &$placeholderIndex) {
|
||||
// // Convert newlines to <br /> in the content before replacing
|
||||
// $contentIndex = count($matches) - 1;
|
||||
// $matches[$contentIndex] = nl2br($matches[$contentIndex]);
|
||||
|
||||
// Replace this BBCode but don't allow nested parsing
|
||||
$result = $this->replaceBBCode($matches, $replacement, $params);
|
||||
$result = $this->replaceBBCode($matches, $replacement, $params, $tag);
|
||||
|
||||
// Store the result and use a placeholder
|
||||
$placeholder = "___BBCODE_PROTECTED_{$placeholderIndex}___";
|
||||
@@ -87,8 +87,8 @@ class BBCodeService {
|
||||
|
||||
$escapedContent = preg_replace_callback(
|
||||
$pattern,
|
||||
function ($matches) use ($replacement, $params) {
|
||||
return $this->replaceBBCode($matches, $replacement, $params);
|
||||
function ($matches) use ($replacement, $params, $tag) {
|
||||
return $this->replaceBBCode($matches, $replacement, $params, $tag);
|
||||
},
|
||||
$escapedContent
|
||||
);
|
||||
@@ -156,16 +156,28 @@ class BBCodeService {
|
||||
// Build pattern to capture each parameter
|
||||
$paramPattern = '';
|
||||
$isFirst = true;
|
||||
$quotePattern = '(?:"|'|')';
|
||||
|
||||
foreach ($params as $param) {
|
||||
$escapedParam = preg_quote($param, '/');
|
||||
// Match: param="value" or param='value' (after HTML escaping: ", ', or ')
|
||||
// First parameter: must be preceded by whitespace
|
||||
// Subsequent parameters: optional and preceded by whitespace
|
||||
$quotePattern = '(?:"|'|')';
|
||||
|
||||
if ($isFirst) {
|
||||
$paramPattern .= '\s+' . $escapedParam . '=' . $quotePattern . '(.*?)' . $quotePattern;
|
||||
// First parameter: if it matches the tag name, support shorthand [tag="value"]
|
||||
// Otherwise require explicit [tag param="value"]
|
||||
if ($param === $tag) {
|
||||
// Support both [color="red"] and [color color="red"]
|
||||
$paramPattern .= '(?:';
|
||||
$paramPattern .= '\s+' . $escapedParam . '=' . $quotePattern . '(.*?)' . $quotePattern; // Explicit
|
||||
$paramPattern .= '|';
|
||||
$paramPattern .= '=' . $quotePattern . '(.*?)' . $quotePattern; // Shorthand
|
||||
$paramPattern .= ')';
|
||||
} else {
|
||||
// Regular first parameter
|
||||
$paramPattern .= '\s+' . $escapedParam . '=' . $quotePattern . '(.*?)' . $quotePattern;
|
||||
}
|
||||
$isFirst = false;
|
||||
} else {
|
||||
// Subsequent parameters are always optional
|
||||
$paramPattern .= '(?:\s+' . $escapedParam . '=' . $quotePattern . '(.*?)' . $quotePattern . ')?';
|
||||
}
|
||||
}
|
||||
@@ -179,9 +191,10 @@ class BBCodeService {
|
||||
* @param array<string> $matches Regex matches
|
||||
* @param string $replacement The replacement template
|
||||
* @param array<string> $params Array of parameter names
|
||||
* @param string $tag The BBCode tag name
|
||||
* @return string The replaced HTML
|
||||
*/
|
||||
private function replaceBBCode(array $matches, string $replacement, array $params): string {
|
||||
private function replaceBBCode(array $matches, string $replacement, array $params, string $tag): string {
|
||||
// The content is always the last match
|
||||
$content = end($matches);
|
||||
|
||||
@@ -192,12 +205,87 @@ class BBCodeService {
|
||||
$result = str_replace('{content}', $content, $result);
|
||||
|
||||
// Replace parameter placeholders with their values
|
||||
foreach ($params as $index => $param) {
|
||||
// Parameter values are in matches starting from index 1
|
||||
$value = $matches[$index + 1] ?? '';
|
||||
$matchIndex = 1;
|
||||
foreach ($params as $paramIndex => $param) {
|
||||
$value = '';
|
||||
|
||||
// First parameter might have shorthand syntax (two capture groups)
|
||||
if ($paramIndex === 0 && $param === $tag) {
|
||||
// Check both capture groups (explicit and shorthand)
|
||||
$value = ($matches[$matchIndex] ?? '') ?: ($matches[$matchIndex + 1] ?? '');
|
||||
$matchIndex += 2; // Skip both capture groups
|
||||
} else {
|
||||
// Regular parameter
|
||||
$value = $matches[$matchIndex] ?? '';
|
||||
$matchIndex++;
|
||||
}
|
||||
|
||||
// Sanitize the parameter value to prevent injection attacks
|
||||
$value = $this->sanitizeParameterValue($param, $value);
|
||||
|
||||
$result = str_replace('{' . $param . '}', $value, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a parameter value to prevent XSS/injection attacks
|
||||
*
|
||||
* @param string $paramName The parameter name
|
||||
* @param string $value The parameter value
|
||||
* @return string The sanitized value (empty string if invalid)
|
||||
*/
|
||||
private function sanitizeParameterValue(string $paramName, string $value): string {
|
||||
// Trim whitespace
|
||||
$value = trim($value);
|
||||
|
||||
// Parameter-specific validation
|
||||
switch ($paramName) {
|
||||
case 'color':
|
||||
// Validate color values - only allow valid CSS colors
|
||||
// Hex colors: #RGB or #RRGGBB
|
||||
if (preg_match('/^#[0-9a-fA-F]{3}$/', $value) || preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
// Named colors (basic validation - alphanumeric only)
|
||||
if (preg_match('/^[a-z]+$/i', $value)) {
|
||||
return $value;
|
||||
}
|
||||
// RGB/RGBA: rgb(r, g, b) or rgba(r, g, b, a)
|
||||
if (preg_match('/^rgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)$/i', $value)) {
|
||||
return $value;
|
||||
}
|
||||
// HSL/HSLA: hsl(h, s%, l%) or hsla(h, s%, l%, a)
|
||||
if (preg_match('/^hsla?\s*\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*(?:,\s*[\d.]+\s*)?\)$/i', $value)) {
|
||||
return $value;
|
||||
}
|
||||
// Invalid color - return empty to remove the attribute
|
||||
return '';
|
||||
|
||||
case 'url':
|
||||
case 'href':
|
||||
case 'src':
|
||||
// Block dangerous protocols
|
||||
$dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:', 'about:'];
|
||||
foreach ($dangerousProtocols as $protocol) {
|
||||
if (stripos($value, $protocol) === 0) {
|
||||
return ''; // Invalid URL - return empty
|
||||
}
|
||||
}
|
||||
// Only allow http://, https://, //, or relative paths
|
||||
if (preg_match('/^(https?:\/\/|\/\/|\/|[a-z0-9.-]+)/i', $value)) {
|
||||
return $value;
|
||||
}
|
||||
return '';
|
||||
|
||||
default:
|
||||
// For unknown parameters, strip any characters that could break out of HTML attributes
|
||||
// Remove quotes, angle brackets, and other dangerous characters
|
||||
$value = str_replace(['"', "'", '<', '>', '`', '\\'], '', $value);
|
||||
// Also remove semicolons to prevent CSS injection in style attributes
|
||||
$value = str_replace(';', '', $value);
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,93 @@ class BBCodeServiceTest extends TestCase {
|
||||
$this->assertStringContainsString('<a href="https://example.com">Link</a>', $result2);
|
||||
}
|
||||
|
||||
public function testParseWithShorthandSyntaxWhenParameterMatchesTag(): void {
|
||||
// When parameter name matches tag name, support shorthand [color="red"]
|
||||
$colorCode = $this->createBBCode('color', '<span style="color:{color}">{content}</span>', true, true);
|
||||
$content = '[color="red"]Red text[/color]';
|
||||
$expected = '<span style="color:red">Red text</span>';
|
||||
|
||||
$result = $this->service->parse($content, [$colorCode]);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseWithExplicitSyntaxStillWorksWhenParameterMatchesTag(): void {
|
||||
// Explicit syntax should still work: [color color="red"]
|
||||
$colorCode = $this->createBBCode('color', '<span style="color:{color}">{content}</span>', true, true);
|
||||
$content = '[color color="blue"]Blue text[/color]';
|
||||
$expected = '<span style="color:blue">Blue text</span>';
|
||||
|
||||
$result = $this->service->parse($content, [$colorCode]);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseShorthandDoesNotApplyWhenParameterDoesNotMatchTag(): void {
|
||||
// If parameter doesn't match tag name, shorthand should not work
|
||||
$urlCode = $this->createBBCode('link', '<a href="{url}">{content}</a>', true, true);
|
||||
// This should NOT match because parameter is "url" but tag is "link"
|
||||
$content = '[link="https://example.com"]Link[/link]';
|
||||
|
||||
$result = $this->service->parse($content, [$urlCode]);
|
||||
|
||||
// Should not be parsed because shorthand doesn't apply
|
||||
$this->assertStringContainsString('[link=', $result);
|
||||
}
|
||||
|
||||
public function testParseShorthandWithMultipleColors(): void {
|
||||
$colorCode = $this->createBBCode('color', '<span style="color:{color}">{content}</span>', true, true);
|
||||
$content = 'This is [color="red"]red[/color] and [color="green"]green[/color] text.';
|
||||
$expected = 'This is <span style="color:red">red</span> and <span style="color:green">green</span> text.';
|
||||
|
||||
$result = $this->service->parse($content, [$colorCode]);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParsePreventsCSS_InjectionInColorParameter(): void {
|
||||
// Attempt to inject additional CSS through color parameter
|
||||
$colorCode = $this->createBBCode('color', '<span style="color:{color}">{content}</span>', true, true);
|
||||
$content = '[color="red;font-weight:bold"]text[/color]';
|
||||
|
||||
$result = $this->service->parse($content, [$colorCode]);
|
||||
|
||||
// Should not contain the injected font-weight style
|
||||
$this->assertStringNotContainsString('font-weight:bold', $result);
|
||||
// Should strip the injection and only use valid color or remove the tag entirely
|
||||
$this->assertStringNotContainsString('style="color:red;font-weight:bold"', $result);
|
||||
}
|
||||
|
||||
public function testParsePreventsJavaScriptInjectionInURLParameter(): void {
|
||||
// Attempt to inject JavaScript through URL parameter
|
||||
$urlCode = $this->createBBCode('url', '<a href="{url}">{content}</a>', true, true);
|
||||
$content = '[url url="javascript:alert(\'XSS\')"]Click me[/url]';
|
||||
|
||||
$result = $this->service->parse($content, [$urlCode]);
|
||||
|
||||
// Should not contain javascript: protocol
|
||||
$this->assertStringNotContainsString('javascript:', $result);
|
||||
}
|
||||
|
||||
public function testParseAllowsValidColorValues(): void {
|
||||
$colorCode = $this->createBBCode('color', '<span style="color:{color}">{content}</span>', true, true);
|
||||
|
||||
// Test various valid color formats
|
||||
$tests = [
|
||||
'[color="red"]text[/color]' => 'red',
|
||||
'[color="#ff0000"]text[/color]' => '#ff0000',
|
||||
'[color="#f00"]text[/color]' => '#f00',
|
||||
'[color="rgb(255, 0, 0)"]text[/color]' => 'rgb(255, 0, 0)',
|
||||
'[color="rgba(255, 0, 0, 0.5)"]text[/color]' => 'rgba(255, 0, 0, 0.5)',
|
||||
'[color="hsl(0, 100%, 50%)"]text[/color]' => 'hsl(0, 100%, 50%)',
|
||||
];
|
||||
|
||||
foreach ($tests as $input => $expectedColor) {
|
||||
$result = $this->service->parse($input, [$colorCode]);
|
||||
$this->assertStringContainsString("color:$expectedColor", $result, "Failed for: $input");
|
||||
}
|
||||
}
|
||||
|
||||
private function createBBCode(string $tag, string $replacement, bool $enabled, bool $parseInner): BBCode {
|
||||
$bbCode = new BBCode();
|
||||
$bbCode->setTag($tag);
|
||||
|
||||
Reference in New Issue
Block a user