Merge pull request #99 from tractorcow/pulls/better-xss-protection

Better XSS Protection via hashed token
This commit is contained in:
Christopher Pitt 2015-03-30 15:30:21 +13:00
commit 974b4554fb
6 changed files with 534 additions and 296 deletions

View File

@ -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/
- vendor/bin/phpunit comments/tests/

View File

@ -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
*

View File

@ -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);
}
}

View File

@ -14,6 +14,9 @@
"suggest": {
"ezyang/htmlpurifier": "4.*"
},
"require-dev": {
"phpunit/PHPUnit": "~3.7@stable"
},
"extra": {
"branch-alias": {
"dev-master": "1.2.x-dev"

View File

@ -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() {

View File

@ -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
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