From 908726165468e84bdb70fdf3bf6de9f876d9e439 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 27 Mar 2015 17:40:00 +1300 Subject: [PATCH] Better XSS Protection via hashed token Allows moderation links to be generated for users other than the currently logged in user, as it doesn't rely on the current session. --- .travis.yml | 6 +- code/controllers/CommentingController.php | 98 +++---- code/dataobjects/Comment.php | 249 ++++++++++++++--- composer.json | 3 + tests/CommentsTest.php | 156 ++++++++--- tests/CommentsTest.yml | 318 +++++++++++----------- 6 files changed, 534 insertions(+), 296 deletions(-) diff --git a/.travis.yml b/.travis.yml index 04b2f96..33590b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,13 +6,13 @@ php: env: - DB=MYSQL CORE_RELEASE=3.1 - - DB=MYSQL CORE_RELEASE=master + - DB=MYSQL CORE_RELEASE=3 - DB=PGSQL CORE_RELEASE=3.1 matrix: include: - php: 5.4 - env: DB=MYSQL CORE_RELEASE=master + env: DB=MYSQL CORE_RELEASE=3 before_script: - git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support @@ -20,4 +20,4 @@ before_script: - cd ~/builds/ss script: - - phpunit comments/tests/ \ No newline at end of file + - vendor/bin/phpunit comments/tests/ diff --git a/code/controllers/CommentingController.php b/code/controllers/CommentingController.php index 5ce9127..45d330b 100644 --- a/code/controllers/CommentingController.php +++ b/code/controllers/CommentingController.php @@ -130,80 +130,70 @@ class CommentingController extends Controller { * Deletes a given {@link Comment} via the URL. */ public function delete() { - if(!$this->checkSecurityToken($this->request)) { - return $this->httpError(400); - } + $comment = $this->getComment(); + if(!$comment) return $this->httpError(404); + if(!$comment->canDelete()) return $this->httpError(403); + if(!$comment->getSecurityToken()->checkRequest($this->request)) return $this->httpError(400); - if(($comment = $this->getComment()) && $comment->canDelete()) { - $comment->delete(); - - return ($this->request->isAjax()) ? true : $this->redirectBack(); - } + $comment->delete(); - return $this->httpError(404); + return $this->request->isAjax() + ? true + : $this->redirectBack(); } /** * Marks a given {@link Comment} as spam. Removes the comment from display */ public function spam() { - if(!$this->checkSecurityToken($this->request)) { - return $this->httpError(400); - } - $comment = $this->getComment(); + if(!$comment) return $this->httpError(404); + if(!$comment->canEdit()) return $this->httpError(403); + if(!$comment->getSecurityToken()->checkRequest($this->request)) return $this->httpError(400); + + $comment->IsSpam = true; + $comment->Moderated = true; + $comment->write(); - if(($comment = $this->getComment()) && $comment->canEdit()) { - $comment->IsSpam = true; - $comment->Moderated = true; - $comment->write(); - - return ($this->request->isAjax()) ? $comment->renderWith('CommentsInterface_singlecomment') : $this->redirectBack(); - } - - return $this->httpError(404); + return $this->request->isAjax() + ? $comment->renderWith('CommentsInterface_singlecomment') + : $this->redirectBack(); } /** * Marks a given {@link Comment} as ham (not spam). */ public function ham() { - if(!$this->checkSecurityToken($this->request)) { - return $this->httpError(400); - } - $comment = $this->getComment(); + if(!$comment) return $this->httpError(404); + if(!$comment->canEdit()) return $this->httpError(403); + if(!$comment->getSecurityToken()->checkRequest($this->request)) return $this->httpError(400); + + $comment->IsSpam = false; + $comment->Moderated = true; + $comment->write(); - if(($comment = $this->getComment()) && $comment->canEdit()) { - $comment->IsSpam = false; - $comment->Moderated = true; - $comment->write(); - - return ($this->request->isAjax()) ? $comment->renderWith('CommentsInterface_singlecomment') : $this->redirectBack(); - } - - return $this->httpError(404); + return $this->request->isAjax() + ? $comment->renderWith('CommentsInterface_singlecomment') + : $this->redirectBack(); } /** * Marks a given {@link Comment} as approved. */ public function approve() { - if(!$this->checkSecurityToken($this->request)) { - return $this->httpError(400); - } - $comment = $this->getComment(); + if(!$comment) return $this->httpError(404); + if(!$comment->canEdit()) return $this->httpError(403); + if(!$comment->getSecurityToken()->checkRequest($this->request)) return $this->httpError(400); - if(($comment = $this->getComment()) && $comment->canEdit()) { - $comment->IsSpam = false; - $comment->Moderated = true; - $comment->write(); - - return ($this->request->isAjax()) ? $comment->renderWith('CommentsInterface_singlecomment') : $this->redirectBack(); - } + $comment->IsSpam = false; + $comment->Moderated = true; + $comment->write(); - return $this->httpError(404); + return $this->request->isAjax() + ? $comment->renderWith('CommentsInterface_singlecomment') + : $this->redirectBack(); } /** @@ -226,20 +216,6 @@ class CommentingController extends Controller { return false; } - /** - * Checks the security token given with the URL to prevent CSRF attacks - * against administrators allowing users to hijack comment moderation. - * - * @param SS_HTTPRequest - * - * @return boolean - */ - public function checkSecurityToken($req) { - $token = SecurityToken::inst(); - - return $token->checkRequest($req); - } - /** * Post a comment form * diff --git a/code/dataobjects/Comment.php b/code/dataobjects/Comment.php index 019faab..19ce7f9 100755 --- a/code/dataobjects/Comment.php +++ b/code/dataobjects/Comment.php @@ -2,7 +2,18 @@ /** * Represents a single comment object. - * + * + * @property string $Name + * @property string $Comment + * @property string $Email + * @property string $URL + * @property string $BaseClass + * @property boolean $Moderated + * @property boolean $IsSpam True if the comment is known as spam + * @property integer $ParentID ID of the parent page / dataobject + * @property boolean $AllowHtml If true, treat $Comment as HTML instead of plain text + * @property string $SecretToken Secret admin token required to provide moderation links between sessions + * @method Member Author() * @package comments */ class Comment extends DataObject { @@ -16,7 +27,8 @@ class Comment extends DataObject { "Moderated" => "Boolean", "IsSpam" => "Boolean", "ParentID" => "Int", - 'AllowHtml' => "Boolean" + 'AllowHtml' => "Boolean", + "SecretToken" => "Varchar(255)", ); private static $has_one = array( @@ -36,7 +48,12 @@ class Comment extends DataObject { private static $casting = array( 'AuthorName' => 'Varchar', - 'RSSName' => 'Varchar' + 'RSSName' => 'Varchar', + 'DeleteLink' => 'Varchar', + 'SpamLink' => 'Varchar', + 'HamLink' => 'Varchar', + 'ApproveLink' => 'Varchar', + 'Permalink' => 'Varchar', ); private static $searchable_fields = array( @@ -64,6 +81,13 @@ class Comment extends DataObject { $this->Comment = $this->purifyHtml($this->Comment); } } + + /** + * @return Comment_SecurityToken + */ + public function getSecurityToken() { + return Injector::inst()->createWithArgs('Comment_SecurityToken', array($this)); + } /** * Migrates the old {@link PageComment} objects to {@link Comment} @@ -100,9 +124,9 @@ class Comment extends DataObject { * @return string link to this comment. */ public function Link($action = "") { - if($parent = $this->getParent()){ - return $parent->Link($action) . '#' . $this->Permalink(); - } + if($parent = $this->getParent()){ + return $parent->Link($action) . '#' . $this->Permalink(); + } } /** @@ -283,55 +307,70 @@ class Comment extends DataObject { } /** + * Generate a secure admin-action link authorised for the specified member + * + * @param string $action An action on CommentingController to link to + * @param Member $member The member authorised to invoke this action * @return string */ - public function DeleteLink() { - if($this->canDelete()) { - $token = SecurityToken::inst(); + protected function actionLink($action, $member = null) { + if(!$member) $member = Member::currentUser(); + if(!$member) return false; - return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf( - "CommentingController/delete/%s", (int) $this->ID - )))); - } + $url = Controller::join_links( + Director::baseURL(), + "CommentingController", + $action, + $this->ID + ); + + // Limit access for this user + $token = $this->getSecurityToken(); + return $token->addToUrl($url, $member); + } + + /** + * Link to delete this comment + * + * @param Member $member + * @return string + */ + public function DeleteLink($member = null) { + if(!$this->canDelete($member)) return false; + return $this->actionLink('delete', $member); } /** + * Link to mark as spam + * + * @param Member $member * @return string */ - public function SpamLink() { - if($this->canEdit() && !$this->IsSpam) { - $token = SecurityToken::inst(); - - return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf( - "CommentingController/spam/%s", (int) $this->ID - )))); - } + public function SpamLink($member = null) { + if(!$this->canEdit($member) || $this->IsSpam) return false; + return $this->actionLink('spam', $member); } /** + * Link to mark as not-spam (ham) + * + * @param Member $member * @return string */ - public function HamLink() { - if($this->canEdit() && $this->IsSpam) { - $token = SecurityToken::inst(); - - return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf( - "CommentingController/ham/%s", (int) $this->ID - )))); - } + public function HamLink($member = null) { + if(!$this->canEdit($member) || !$this->IsSpam) return false; + return $this->actionLink('ham', $member); } /** + * Link to approve this comment + * + * @param Member $member * @return string */ - public function ApproveLink() { - if($this->canEdit() && !$this->Moderated) { - $token = SecurityToken::inst(); - - return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf( - "CommentingController/approve/%s", (int) $this->ID - )))); - } + public function ApproveLink($member = null) { + if(!$this->canEdit($member) || $this->Moderated) return false; + return $this->actionLink('approve', $member); } /** @@ -367,9 +406,8 @@ class Comment extends DataObject { */ public function getCMSFields() { $fields = parent::getCMSFields(); - $parent = $this->getParent()->ID; - $hidden = array('ParentID', 'AuthorID', 'BaseClass', 'AllowHtml'); + $hidden = array('ParentID', 'AuthorID', 'BaseClass', 'AllowHtml', 'SecretToken'); foreach($hidden as $private) { $fields->removeByName($private); @@ -419,3 +457,136 @@ class Comment extends DataObject { return $gravatar; } } + +/** + * Provides the ability to generate cryptographically secure tokens for comment moderation + */ +class Comment_SecurityToken { + + private $secret = null; + + /** + * @param Comment $comment Comment to generate this token for + */ + public function __construct($comment) { + if(!$comment->SecretToken) { + $comment->SecretToken = $this->generate(); + $comment->write(); + } + $this->secret = $comment->SecretToken; + } + + /** + * Generate the token for the given salt and current secret + * + * @param string $salt + * @return string + */ + protected function getToken($salt) { + return function_exists('hash_pbkdf2') + ? hash_pbkdf2('sha256', $this->secret, $salt, 1000, 30) + : $this->hash_pbkdf2('sha256', $this->secret, $salt, 100, 30); + } + + /** + * Get the member-specific salt. + * + * The reason for making the salt specific to a user is that it cannot be "passed in" via a querystring, + * requiring the same user to be present at both the link generation and the controller action. + * + * @param string $salt Single use salt + * @param type $member Member object + * @return string Generated salt specific to this member + */ + protected function memberSalt($salt, $member) { + // Fallback to salting with ID in case the member has not one set + return $salt . ($member->Salt ?: $member->ID); + } + + /** + * @param string $url Comment action URL + * @param Member $member Member to restrict access to this action to + * @return string + */ + public function addToUrl($url, $member) { + $salt = $this->generate(15); // New random salt; Will be passed into url + // Generate salt specific to this member + $memberSalt = $this->memberSalt($salt, $member); + $token = $this->getToken($memberSalt); + return Controller::join_links( + $url, + sprintf( + '?t=%s&s=%s', + urlencode($token), + urlencode($salt) + ) + ); + } + + /** + * @param SS_HTTPRequest $request + * @return boolean + */ + public function checkRequest($request) { + $member = Member::currentUser(); + if(!$member) return false; + + $salt = $request->getVar('s'); + $memberSalt = $this->memberSalt($salt, $member); + $token = $this->getToken($memberSalt); + + // Ensure tokens match + return $token === $request->getVar('t'); + } + + + /** + * Generates new random key + * + * @param integer $length + * @return string + */ + protected function generate($length = null) { + $generator = new RandomGenerator(); + $result = $generator->randomToken('sha256'); + if($length !== null) return substr ($result, 0, $length); + return $result; + } + + /*----------------------------------------------------------- + * PBKDF2 Implementation (described in RFC 2898) from php.net + *----------------------------------------------------------- + * @param string a hash algorithm + * @param string p password + * @param string s salt + * @param int c iteration count (use 1000 or higher) + * @param int kl derived key length + * @param int st start position of result + * + * @return string derived key + */ + private function hash_pbkdf2 ($a, $p, $s, $c, $kl, $st=0) { + + $kb = $st+$kl; // Key blocks to compute + $dk = ''; // Derived key + + // Create key + for ($block=1; $block<=$kb; $block++) { + + // Initial hash for this block + $ib = $h = hash_hmac($a, $s . pack('N', $block), $p, true); + + // Perform block iterations + for ($i=1; $i<$c; $i++) { + // XOR each iterate + $ib ^= ($h = hash_hmac($a, $h, $p, true)); + } + + $dk .= $ib; // Append iterated block + + } + + // Return derived key of correct length + return substr($dk, $st, $kl); + } +} diff --git a/composer.json b/composer.json index 7bfb203..efebff5 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,9 @@ "suggest": { "ezyang/htmlpurifier": "4.*" }, + "require-dev": { + "phpunit/PHPUnit": "~3.7@stable" + }, "extra": { "branch-alias": { "dev-master": "1.2.x-dev" diff --git a/tests/CommentsTest.php b/tests/CommentsTest.php index 44446a6..1b1e160 100644 --- a/tests/CommentsTest.php +++ b/tests/CommentsTest.php @@ -90,82 +90,168 @@ class CommentsTest extends FunctionalTest { } public function testDeleteComment() { + // Test anonymous user + if($member = Member::currentUser()) $member->logOut(); $comment = $this->objFromFixture('Comment', 'firstComA'); - $this->assertNull($comment->DeleteLink(), 'No permission to see delete link'); + $commentID = $comment->ID; + $this->assertFalse($comment->DeleteLink(), 'No permission to see delete link'); $delete = $this->get('CommentingController/delete/'.$comment->ID); - $check = DataObject::get_by_id('Comment', $comment->ID); + $this->assertEquals(403, $delete->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); $this->assertTrue($check && $check->exists()); - $firstPage = $this->objFromFixture('CommentableItem', 'first'); - $this->autoFollowRedirection = false; + // Test non-authenticated user + $this->logInAs('visitor'); + $this->assertFalse($comment->DeleteLink(), 'No permission to see delete link'); + + // Test authenticated user $this->logInAs('commentadmin'); - - $firstComment = $this->objFromFixture('Comment', 'firstComA'); - $firstCommentID = $firstComment->ID; - Director::test($firstPage->RelativeLink(), null, $this->session()); - $delete = $this->get('CommentingController/delete/'.$firstComment->ID); - $check = DataObject::get_by_id('Comment', $firstCommentID); + $comment = $this->objFromFixture('Comment', 'firstComA'); + $commentID = $comment->ID; + $adminComment1Link = $comment->DeleteLink(); + $this->assertContains('CommentingController/delete/'.$commentID.'?t=', $adminComment1Link); + + // Test that this link can't be shared / XSS exploited + $this->logInAs('commentadmin2'); + $delete = $this->get($adminComment1Link); + $this->assertEquals(400, $delete->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); + $this->assertTrue($check && $check->exists()); + + // Test that this other admin can delete the comment with their own link + $adminComment2Link = $comment->DeleteLink(); + $this->assertNotEquals($adminComment2Link, $adminComment1Link); + $this->autoFollowRedirection = false; + $delete = $this->get($adminComment2Link); + $this->assertEquals(302, $delete->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); $this->assertFalse($check && $check->exists()); } public function testSpamComment() { + // Test anonymous user + if($member = Member::currentUser()) $member->logOut(); $comment = $this->objFromFixture('Comment', 'firstComA'); - $this->assertNull($comment->SpamLink(), 'No permission to see mark as spam link'); + $commentID = $comment->ID; + $this->assertFalse($comment->SpamLink(), 'No permission to see mark as spam link'); $spam = $this->get('CommentingController/spam/'.$comment->ID); + $this->assertEquals(403, $spam->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); + $this->assertEquals(0, $check->IsSpam, 'No permission to mark as spam'); + // Test non-authenticated user + $this->logInAs('visitor'); + $this->assertFalse($comment->SpamLink(), 'No permission to see mark as spam link'); + + // Test authenticated user + $this->logInAs('commentadmin'); + $comment = $this->objFromFixture('Comment', 'firstComA'); + $commentID = $comment->ID; + $adminComment1Link = $comment->SpamLink(); + $this->assertContains('CommentingController/spam/'.$commentID.'?t=', $adminComment1Link); + + // Test that this link can't be shared / XSS exploited + $this->logInAs('commentadmin2'); + $spam = $this->get($adminComment1Link); + $this->assertEquals(400, $spam->getStatusCode()); $check = DataObject::get_by_id('Comment', $comment->ID); $this->assertEquals(0, $check->IsSpam, 'No permission to mark as spam'); + // Test that this other admin can spam the comment with their own link + $adminComment2Link = $comment->SpamLink(); + $this->assertNotEquals($adminComment2Link, $adminComment1Link); $this->autoFollowRedirection = false; - $this->logInAs('commentadmin'); - - $this->assertContains('CommentingController/spam/'. $comment->ID, $comment->SpamLink()->getValue()); - - $spam = $this->get('CommentingController/spam/'.$comment->ID); - $check = DataObject::get_by_id('Comment', $comment->ID); + $spam = $this->get($adminComment2Link); + $this->assertEquals(302, $spam->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); $this->assertEquals(1, $check->IsSpam); - $this->assertNull($check->SpamLink()); + // Cannot re-spam spammed comment + $this->assertFalse($check->SpamLink()); } public function testHamComment() { + // Test anonymous user + if($member = Member::currentUser()) $member->logOut(); $comment = $this->objFromFixture('Comment', 'secondComC'); - $this->assertNull($comment->HamLink(), 'No permission to see mark as ham link'); + $commentID = $comment->ID; + $this->assertFalse($comment->HamLink(), 'No permission to see mark as ham link'); $ham = $this->get('CommentingController/ham/'.$comment->ID); + $this->assertEquals(403, $ham->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); + $this->assertEquals(1, $check->IsSpam, 'No permission to mark as ham'); + // Test non-authenticated user + $this->logInAs('visitor'); + $this->assertFalse($comment->HamLink(), 'No permission to see mark as ham link'); + + // Test authenticated user + $this->logInAs('commentadmin'); + $comment = $this->objFromFixture('Comment', 'secondComC'); + $commentID = $comment->ID; + $adminComment1Link = $comment->HamLink(); + $this->assertContains('CommentingController/ham/'.$commentID.'?t=', $adminComment1Link); + + // Test that this link can't be shared / XSS exploited + $this->logInAs('commentadmin2'); + $ham = $this->get($adminComment1Link); + $this->assertEquals(400, $ham->getStatusCode()); $check = DataObject::get_by_id('Comment', $comment->ID); $this->assertEquals(1, $check->IsSpam, 'No permission to mark as ham'); + // Test that this other admin can ham the comment with their own link + $adminComment2Link = $comment->HamLink(); + $this->assertNotEquals($adminComment2Link, $adminComment1Link); $this->autoFollowRedirection = false; - $this->logInAs('commentadmin'); - - $this->assertContains('CommentingController/ham/'. $comment->ID, $comment->HamLink()->getValue()); - - $ham = $this->get('CommentingController/ham/'.$comment->ID); - $check = DataObject::get_by_id('Comment', $comment->ID); + $ham = $this->get($adminComment2Link); + $this->assertEquals(302, $ham->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); $this->assertEquals(0, $check->IsSpam); - $this->assertNull($check->HamLink()); + // Cannot re-ham hammed comment + $this->assertFalse($check->HamLink()); } public function testApproveComment() { + // Test anonymous user + if($member = Member::currentUser()) $member->logOut(); $comment = $this->objFromFixture('Comment', 'secondComB'); - $this->assertNull($comment->ApproveLink(), 'No permission to see mark as approved link'); - $ham = $this->get('CommentingController/approve/'.$comment->ID); + $commentID = $comment->ID; + $this->assertFalse($comment->ApproveLink(), 'No permission to see approve link'); + $approve = $this->get('CommentingController/approve/'.$comment->ID); + $this->assertEquals(403, $approve->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); + $this->assertEquals(0, $check->Moderated, 'No permission to approve'); - $check = DataObject::get_by_id('Comment', $comment->ID); - $this->assertEquals(0, $check->Moderated, 'No permission to mark as approved'); + // Test non-authenticated user + $this->logInAs('visitor'); + $this->assertFalse($comment->ApproveLink(), 'No permission to see approve link'); - $this->autoFollowRedirection = false; + // Test authenticated user $this->logInAs('commentadmin'); + $comment = $this->objFromFixture('Comment', 'secondComB'); + $commentID = $comment->ID; + $adminComment1Link = $comment->ApproveLink(); + $this->assertContains('CommentingController/approve/'.$commentID.'?t=', $adminComment1Link); - $this->assertContains('CommentingController/approve/'. $comment->ID, $comment->ApproveLink()->getValue()); - - $ham = $this->get('CommentingController/approve/'.$comment->ID); + // Test that this link can't be shared / XSS exploited + $this->logInAs('commentadmin2'); + $approve = $this->get($adminComment1Link); + $this->assertEquals(400, $approve->getStatusCode()); $check = DataObject::get_by_id('Comment', $comment->ID); + $this->assertEquals(0, $check->Moderated, 'No permission to approve'); + + // Test that this other admin can approve the comment with their own link + $adminComment2Link = $comment->ApproveLink(); + $this->assertNotEquals($adminComment2Link, $adminComment1Link); + $this->autoFollowRedirection = false; + $approve = $this->get($adminComment2Link); + $this->assertEquals(302, $approve->getStatusCode()); + $check = DataObject::get_by_id('Comment', $commentID); $this->assertEquals(1, $check->Moderated); - $this->assertNull($check->ApproveLink()); + // Cannot re-approve approved comment + $this->assertFalse($check->ApproveLink()); } public function testCommenterURLWrite() { diff --git a/tests/CommentsTest.yml b/tests/CommentsTest.yml index 2c2ae2e..55543ae 100644 --- a/tests/CommentsTest.yml +++ b/tests/CommentsTest.yml @@ -1,167 +1,169 @@ Member: - commentadmin: - FirstName: admin - visitor: - FirstName: visitor + commentadmin: + FirstName: admin + commentadmin2: + FirstName: admin2 + visitor: + FirstName: visitor Group: - commentadmins: - Title: Admin - Members: =>Member.commentadmin + commentadmins: + Title: Admin + Members: =>Member.commentadmin, =>Member.commentadmin2 Permission: - admin: - Code: CMS_ACCESS_CommentAdmin - Group: =>Group.commentadmins + admin: + Code: CMS_ACCESS_CommentAdmin + Group: =>Group.commentadmins CommentableItem: - first: - Title: First - ProvideComments: 1 - second: - Title: Second - ProvideComments: 1 - third: - Title: Third - ProvideComments: 1 - nocomments: - Title: No comments - ProvideComments: 0 - spammed: - ProvideComments: 1 - Title: spammed + first: + Title: First + ProvideComments: 1 + second: + Title: Second + ProvideComments: 1 + third: + Title: Third + ProvideComments: 1 + nocomments: + Title: No comments + ProvideComments: 0 + spammed: + ProvideComments: 1 + Title: spammed Comment: - firstComA: - ParentID: =>CommentableItem.first - Name: FA - Comment: textFA - BaseClass: CommentableItem - Moderated: 1 - IsSpam: 0 - secondComA: - ParentID: =>CommentableItem.second - Name: SA - Comment: textSA - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - secondComB: - ParentID: =>CommentableItem.second - Name: SB - Comment: textSB - Moderated: 0 - IsSpam: 0 - BaseClass: CommentableItem - secondComC: - ParentID: =>CommentableItem.second - Name: SB - Comment: textSB - Moderated: 1 - IsSpam: 1 - BaseClass: CommentableItem - thirdComA: - ParentID: =>CommentableItem.third - Name: TA - Comment: textTA - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComB: - ParentID: =>CommentableItem.third - Name: TB - Comment: textTB - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComC: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComD: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - BaseClass: CommentableItem - thirdComE: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - BaseClass: CommentableItem - thirdComF: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComG: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComH: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComI: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComJ: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - thirdComK: - ParentID: =>CommentableItem.third - Name: TC - Comment: textTC - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - disabledCom: - ParentID: =>CommentableItem.nocomments - Name: Disabled - Moderated: 0 - IsSpam: 1 - BaseClass: CommentableItem - testCommentList1: - ParentID: =>CommentableItem.spammed - Name: Comment 1 - Moderated: 0 - IsSpam: 0 - BaseClass: CommentableItem - testCommentList2: - ParentID: =>CommentableItem.spammed - Name: Comment 2 - Moderated: 1 - IsSpam: 1 - BaseClass: CommentableItem - testCommentList3: - ParentID: =>CommentableItem.spammed - Name: Comment 3 - Moderated: 1 - IsSpam: 0 - BaseClass: CommentableItem - testCommentList4: - ParentID: =>CommentableItem.spammed - Name: Comment 4 - Moderated: 0 - IsSpam: 1 - BaseClass: CommentableItem \ No newline at end of file + firstComA: + ParentID: =>CommentableItem.first + Name: FA + Comment: textFA + BaseClass: CommentableItem + Moderated: 1 + IsSpam: 0 + secondComA: + ParentID: =>CommentableItem.second + Name: SA + Comment: textSA + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + secondComB: + ParentID: =>CommentableItem.second + Name: SB + Comment: textSB + Moderated: 0 + IsSpam: 0 + BaseClass: CommentableItem + secondComC: + ParentID: =>CommentableItem.second + Name: SB + Comment: textSB + Moderated: 1 + IsSpam: 1 + BaseClass: CommentableItem + thirdComA: + ParentID: =>CommentableItem.third + Name: TA + Comment: textTA + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComB: + ParentID: =>CommentableItem.third + Name: TB + Comment: textTB + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComC: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComD: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + BaseClass: CommentableItem + thirdComE: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + BaseClass: CommentableItem + thirdComF: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComG: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComH: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComI: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComJ: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + thirdComK: + ParentID: =>CommentableItem.third + Name: TC + Comment: textTC + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + disabledCom: + ParentID: =>CommentableItem.nocomments + Name: Disabled + Moderated: 0 + IsSpam: 1 + BaseClass: CommentableItem + testCommentList1: + ParentID: =>CommentableItem.spammed + Name: Comment 1 + Moderated: 0 + IsSpam: 0 + BaseClass: CommentableItem + testCommentList2: + ParentID: =>CommentableItem.spammed + Name: Comment 2 + Moderated: 1 + IsSpam: 1 + BaseClass: CommentableItem + testCommentList3: + ParentID: =>CommentableItem.spammed + Name: Comment 3 + Moderated: 1 + IsSpam: 0 + BaseClass: CommentableItem + testCommentList4: + ParentID: =>CommentableItem.spammed + Name: Comment 4 + Moderated: 0 + IsSpam: 1 + BaseClass: CommentableItem \ No newline at end of file