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: env:
- DB=MYSQL CORE_RELEASE=3.1 - DB=MYSQL CORE_RELEASE=3.1
- DB=MYSQL CORE_RELEASE=master - DB=MYSQL CORE_RELEASE=3
- DB=PGSQL CORE_RELEASE=3.1 - DB=PGSQL CORE_RELEASE=3.1
matrix: matrix:
include: include:
- php: 5.4 - php: 5.4
env: DB=MYSQL CORE_RELEASE=master env: DB=MYSQL CORE_RELEASE=3
before_script: before_script:
- git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support - git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support
@ -20,4 +20,4 @@ before_script:
- cd ~/builds/ss - cd ~/builds/ss
script: 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. * Deletes a given {@link Comment} via the URL.
*/ */
public function delete() { public function delete() {
if(!$this->checkSecurityToken($this->request)) { $comment = $this->getComment();
return $this->httpError(400); 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();
$comment->delete();
return ($this->request->isAjax()) ? true : $this->redirectBack(); return $this->request->isAjax()
} ? true
: $this->redirectBack();
return $this->httpError(404);
} }
/** /**
* Marks a given {@link Comment} as spam. Removes the comment from display * Marks a given {@link Comment} as spam. Removes the comment from display
*/ */
public function spam() { public function spam() {
if(!$this->checkSecurityToken($this->request)) {
return $this->httpError(400);
}
$comment = $this->getComment(); $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 = true;
$comment->IsSpam = true; $comment->Moderated = true;
$comment->Moderated = true; $comment->write();
$comment->write();
return ($this->request->isAjax()) ? $comment->renderWith('CommentsInterface_singlecomment') : $this->redirectBack(); return $this->request->isAjax()
} ? $comment->renderWith('CommentsInterface_singlecomment')
: $this->redirectBack();
return $this->httpError(404);
} }
/** /**
* Marks a given {@link Comment} as ham (not spam). * Marks a given {@link Comment} as ham (not spam).
*/ */
public function ham() { public function ham() {
if(!$this->checkSecurityToken($this->request)) {
return $this->httpError(400);
}
$comment = $this->getComment(); $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->IsSpam = false; $comment->Moderated = true;
$comment->Moderated = true; $comment->write();
$comment->write();
return ($this->request->isAjax()) ? $comment->renderWith('CommentsInterface_singlecomment') : $this->redirectBack(); return $this->request->isAjax()
} ? $comment->renderWith('CommentsInterface_singlecomment')
: $this->redirectBack();
return $this->httpError(404);
} }
/** /**
* Marks a given {@link Comment} as approved. * Marks a given {@link Comment} as approved.
*/ */
public function approve() { public function approve() {
if(!$this->checkSecurityToken($this->request)) {
return $this->httpError(400);
}
$comment = $this->getComment(); $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->IsSpam = false; $comment->Moderated = true;
$comment->Moderated = true; $comment->write();
$comment->write();
return ($this->request->isAjax()) ? $comment->renderWith('CommentsInterface_singlecomment') : $this->redirectBack(); return $this->request->isAjax()
} ? $comment->renderWith('CommentsInterface_singlecomment')
: $this->redirectBack();
return $this->httpError(404);
} }
/** /**
@ -226,20 +216,6 @@ class CommentingController extends Controller {
return false; 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 * Post a comment form
* *

View File

@ -3,6 +3,17 @@
/** /**
* Represents a single comment object. * 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 * @package comments
*/ */
class Comment extends DataObject { class Comment extends DataObject {
@ -16,7 +27,8 @@ class Comment extends DataObject {
"Moderated" => "Boolean", "Moderated" => "Boolean",
"IsSpam" => "Boolean", "IsSpam" => "Boolean",
"ParentID" => "Int", "ParentID" => "Int",
'AllowHtml' => "Boolean" 'AllowHtml' => "Boolean",
"SecretToken" => "Varchar(255)",
); );
private static $has_one = array( private static $has_one = array(
@ -36,7 +48,12 @@ class Comment extends DataObject {
private static $casting = array( private static $casting = array(
'AuthorName' => 'Varchar', 'AuthorName' => 'Varchar',
'RSSName' => 'Varchar' 'RSSName' => 'Varchar',
'DeleteLink' => 'Varchar',
'SpamLink' => 'Varchar',
'HamLink' => 'Varchar',
'ApproveLink' => 'Varchar',
'Permalink' => 'Varchar',
); );
private static $searchable_fields = array( private static $searchable_fields = array(
@ -65,6 +82,13 @@ class Comment extends DataObject {
} }
} }
/**
* @return Comment_SecurityToken
*/
public function getSecurityToken() {
return Injector::inst()->createWithArgs('Comment_SecurityToken', array($this));
}
/** /**
* Migrates the old {@link PageComment} objects to {@link Comment} * Migrates the old {@link PageComment} objects to {@link Comment}
*/ */
@ -100,9 +124,9 @@ class Comment extends DataObject {
* @return string link to this comment. * @return string link to this comment.
*/ */
public function Link($action = "") { public function Link($action = "") {
if($parent = $this->getParent()){ if($parent = $this->getParent()){
return $parent->Link($action) . '#' . $this->Permalink(); 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 * @return string
*/ */
public function DeleteLink() { protected function actionLink($action, $member = null) {
if($this->canDelete()) { if(!$member) $member = Member::currentUser();
$token = SecurityToken::inst(); if(!$member) return false;
return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf( $url = Controller::join_links(
"CommentingController/delete/%s", (int) $this->ID 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 * @return string
*/ */
public function SpamLink() { public function DeleteLink($member = null) {
if($this->canEdit() && !$this->IsSpam) { if(!$this->canDelete($member)) return false;
$token = SecurityToken::inst(); return $this->actionLink('delete', $member);
return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf(
"CommentingController/spam/%s", (int) $this->ID
))));
}
} }
/** /**
* Link to mark as spam
*
* @param Member $member
* @return string * @return string
*/ */
public function HamLink() { public function SpamLink($member = null) {
if($this->canEdit() && $this->IsSpam) { if(!$this->canEdit($member) || $this->IsSpam) return false;
$token = SecurityToken::inst(); return $this->actionLink('spam', $member);
return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf(
"CommentingController/ham/%s", (int) $this->ID
))));
}
} }
/** /**
* Link to mark as not-spam (ham)
*
* @param Member $member
* @return string * @return string
*/ */
public function ApproveLink() { public function HamLink($member = null) {
if($this->canEdit() && !$this->Moderated) { if(!$this->canEdit($member) || !$this->IsSpam) return false;
$token = SecurityToken::inst(); return $this->actionLink('ham', $member);
}
return DBField::create_field("Varchar", Director::absoluteURL($token->addToUrl(sprintf( /**
"CommentingController/approve/%s", (int) $this->ID * Link to approve this comment
)))); *
} * @param Member $member
* @return string
*/
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() { public function getCMSFields() {
$fields = parent::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) { foreach($hidden as $private) {
$fields->removeByName($private); $fields->removeByName($private);
@ -419,3 +457,136 @@ class Comment extends DataObject {
return $gravatar; 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": { "suggest": {
"ezyang/htmlpurifier": "4.*" "ezyang/htmlpurifier": "4.*"
}, },
"require-dev": {
"phpunit/PHPUnit": "~3.7@stable"
},
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.2.x-dev" "dev-master": "1.2.x-dev"

View File

@ -90,82 +90,168 @@ class CommentsTest extends FunctionalTest {
} }
public function testDeleteComment() { public function testDeleteComment() {
// Test anonymous user
if($member = Member::currentUser()) $member->logOut();
$comment = $this->objFromFixture('Comment', 'firstComA'); $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); $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()); $this->assertTrue($check && $check->exists());
$firstPage = $this->objFromFixture('CommentableItem', 'first'); // Test non-authenticated user
$this->autoFollowRedirection = false; $this->logInAs('visitor');
$this->logInAs('commentadmin'); $this->assertFalse($comment->DeleteLink(), 'No permission to see delete link');
$firstComment = $this->objFromFixture('Comment', 'firstComA'); // Test authenticated user
$firstCommentID = $firstComment->ID; $this->logInAs('commentadmin');
Director::test($firstPage->RelativeLink(), null, $this->session()); $comment = $this->objFromFixture('Comment', 'firstComA');
$delete = $this->get('CommentingController/delete/'.$firstComment->ID); $commentID = $comment->ID;
$check = DataObject::get_by_id('Comment', $firstCommentID); $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()); $this->assertFalse($check && $check->exists());
} }
public function testSpamComment() { public function testSpamComment() {
// Test anonymous user
if($member = Member::currentUser()) $member->logOut();
$comment = $this->objFromFixture('Comment', 'firstComA'); $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); $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); $check = DataObject::get_by_id('Comment', $comment->ID);
$this->assertEquals(0, $check->IsSpam, 'No permission to mark as spam'); $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->autoFollowRedirection = false;
$this->logInAs('commentadmin'); $spam = $this->get($adminComment2Link);
$this->assertEquals(302, $spam->getStatusCode());
$this->assertContains('CommentingController/spam/'. $comment->ID, $comment->SpamLink()->getValue()); $check = DataObject::get_by_id('Comment', $commentID);
$spam = $this->get('CommentingController/spam/'.$comment->ID);
$check = DataObject::get_by_id('Comment', $comment->ID);
$this->assertEquals(1, $check->IsSpam); $this->assertEquals(1, $check->IsSpam);
$this->assertNull($check->SpamLink()); // Cannot re-spam spammed comment
$this->assertFalse($check->SpamLink());
} }
public function testHamComment() { public function testHamComment() {
// Test anonymous user
if($member = Member::currentUser()) $member->logOut();
$comment = $this->objFromFixture('Comment', 'secondComC'); $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); $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); $check = DataObject::get_by_id('Comment', $comment->ID);
$this->assertEquals(1, $check->IsSpam, 'No permission to mark as ham'); $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->autoFollowRedirection = false;
$this->logInAs('commentadmin'); $ham = $this->get($adminComment2Link);
$this->assertEquals(302, $ham->getStatusCode());
$this->assertContains('CommentingController/ham/'. $comment->ID, $comment->HamLink()->getValue()); $check = DataObject::get_by_id('Comment', $commentID);
$ham = $this->get('CommentingController/ham/'.$comment->ID);
$check = DataObject::get_by_id('Comment', $comment->ID);
$this->assertEquals(0, $check->IsSpam); $this->assertEquals(0, $check->IsSpam);
$this->assertNull($check->HamLink()); // Cannot re-ham hammed comment
$this->assertFalse($check->HamLink());
} }
public function testApproveComment() { public function testApproveComment() {
// Test anonymous user
if($member = Member::currentUser()) $member->logOut();
$comment = $this->objFromFixture('Comment', 'secondComB'); $comment = $this->objFromFixture('Comment', 'secondComB');
$this->assertNull($comment->ApproveLink(), 'No permission to see mark as approved link'); $commentID = $comment->ID;
$ham = $this->get('CommentingController/approve/'.$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); // Test non-authenticated user
$this->assertEquals(0, $check->Moderated, 'No permission to mark as approved'); $this->logInAs('visitor');
$this->assertFalse($comment->ApproveLink(), 'No permission to see approve link');
$this->autoFollowRedirection = false; // Test authenticated user
$this->logInAs('commentadmin'); $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()); // Test that this link can't be shared / XSS exploited
$this->logInAs('commentadmin2');
$ham = $this->get('CommentingController/approve/'.$comment->ID); $approve = $this->get($adminComment1Link);
$this->assertEquals(400, $approve->getStatusCode());
$check = DataObject::get_by_id('Comment', $comment->ID); $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->assertEquals(1, $check->Moderated);
$this->assertNull($check->ApproveLink()); // Cannot re-approve approved comment
$this->assertFalse($check->ApproveLink());
} }
public function testCommenterURLWrite() { public function testCommenterURLWrite() {

View File

@ -1,167 +1,169 @@
Member: Member:
commentadmin: commentadmin:
FirstName: admin FirstName: admin
visitor: commentadmin2:
FirstName: visitor FirstName: admin2
visitor:
FirstName: visitor
Group: Group:
commentadmins: commentadmins:
Title: Admin Title: Admin
Members: =>Member.commentadmin Members: =>Member.commentadmin, =>Member.commentadmin2
Permission: Permission:
admin: admin:
Code: CMS_ACCESS_CommentAdmin Code: CMS_ACCESS_CommentAdmin
Group: =>Group.commentadmins Group: =>Group.commentadmins
CommentableItem: CommentableItem:
first: first:
Title: First Title: First
ProvideComments: 1 ProvideComments: 1
second: second:
Title: Second Title: Second
ProvideComments: 1 ProvideComments: 1
third: third:
Title: Third Title: Third
ProvideComments: 1 ProvideComments: 1
nocomments: nocomments:
Title: No comments Title: No comments
ProvideComments: 0 ProvideComments: 0
spammed: spammed:
ProvideComments: 1 ProvideComments: 1
Title: spammed Title: spammed
Comment: Comment:
firstComA: firstComA:
ParentID: =>CommentableItem.first ParentID: =>CommentableItem.first
Name: FA Name: FA
Comment: textFA Comment: textFA
BaseClass: CommentableItem BaseClass: CommentableItem
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
secondComA: secondComA:
ParentID: =>CommentableItem.second ParentID: =>CommentableItem.second
Name: SA Name: SA
Comment: textSA Comment: textSA
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
secondComB: secondComB:
ParentID: =>CommentableItem.second ParentID: =>CommentableItem.second
Name: SB Name: SB
Comment: textSB Comment: textSB
Moderated: 0 Moderated: 0
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
secondComC: secondComC:
ParentID: =>CommentableItem.second ParentID: =>CommentableItem.second
Name: SB Name: SB
Comment: textSB Comment: textSB
Moderated: 1 Moderated: 1
IsSpam: 1 IsSpam: 1
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComA: thirdComA:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TA Name: TA
Comment: textTA Comment: textTA
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComB: thirdComB:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TB Name: TB
Comment: textTB Comment: textTB
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComC: thirdComC:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComD: thirdComD:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComE: thirdComE:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComF: thirdComF:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComG: thirdComG:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComH: thirdComH:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComI: thirdComI:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComJ: thirdComJ:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
thirdComK: thirdComK:
ParentID: =>CommentableItem.third ParentID: =>CommentableItem.third
Name: TC Name: TC
Comment: textTC Comment: textTC
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
disabledCom: disabledCom:
ParentID: =>CommentableItem.nocomments ParentID: =>CommentableItem.nocomments
Name: Disabled Name: Disabled
Moderated: 0 Moderated: 0
IsSpam: 1 IsSpam: 1
BaseClass: CommentableItem BaseClass: CommentableItem
testCommentList1: testCommentList1:
ParentID: =>CommentableItem.spammed ParentID: =>CommentableItem.spammed
Name: Comment 1 Name: Comment 1
Moderated: 0 Moderated: 0
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
testCommentList2: testCommentList2:
ParentID: =>CommentableItem.spammed ParentID: =>CommentableItem.spammed
Name: Comment 2 Name: Comment 2
Moderated: 1 Moderated: 1
IsSpam: 1 IsSpam: 1
BaseClass: CommentableItem BaseClass: CommentableItem
testCommentList3: testCommentList3:
ParentID: =>CommentableItem.spammed ParentID: =>CommentableItem.spammed
Name: Comment 3 Name: Comment 3
Moderated: 1 Moderated: 1
IsSpam: 0 IsSpam: 0
BaseClass: CommentableItem BaseClass: CommentableItem
testCommentList4: testCommentList4:
ParentID: =>CommentableItem.spammed ParentID: =>CommentableItem.spammed
Name: Comment 4 Name: Comment 4
Moderated: 0 Moderated: 0
IsSpam: 1 IsSpam: 1
BaseClass: CommentableItem BaseClass: CommentableItem