Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,51 @@ class MyClass {

---

## HTML Sanitization & XSS Protection

**Use `InputUtils` for all HTML/text handling** - Located in `src/ChurchCRM/Utils/InputUtils.php`

Four core methods for security:

1. **`sanitizeText($input)`** - Plain text, removes ALL HTML tags
- Use for: Names, descriptions, social media handles
- Example: `$person->setFirstName(InputUtils::sanitizeText($_POST['firstName']))`

2. **`sanitizeHTML($input)`** - Rich text with XSS protection (HTML Purifier)
- Use for: User-provided HTML content (event descriptions, Quill editor)
- Allows safe tags: `<a><b><i><u><h1-h6><pre><img><table><p><blockquote><div><code>` etc.
- Blocks dangerous: `<script><iframe><embed><form><style><meta>`
- Example: `$event->setDesc(InputUtils::sanitizeHTML($sEventDesc))`

3. **`escapeHTML($input)`** - Output escaping for HTML body content
- Automatically handles `stripslashes()` for magic quotes
- Use for: Displaying database/user values in HTML
- Example: `<?= InputUtils::escapeHTML($person->getFirstName()) ?>`

4. **`escapeAttribute($input)`** - Output escaping for HTML attributes
- Same security as `escapeHTML()` (uses `ENT_QUOTES`)
- Use for: Values in HTML attributes or form fields
- Example: `<input value="<?= InputUtils::escapeAttribute($address) ?>">`

5. **`sanitizeAndEscapeText($input)`** - Combined plain text sanitization + output escape
- Use for: Untrusted user input that must be plain text and escaped
- Example: `$data[$key] = InputUtils::sanitizeAndEscapeText($userSubmittedValue)`

**CRITICAL Security Rules:**
- ❌ NEVER use `htmlspecialchars()` or `htmlentities()` directly
- ❌ NEVER use `ENT_NOQUOTES` flag (doesn't escape quotes in attributes)
- ❌ NEVER use `stripslashes()` directly (let InputUtils handle it)
- ✅ ALWAYS use InputUtils methods for all HTML/text handling
- ✅ ALWAYS use `escapeAttribute()` for form input values
- ✅ ALWAYS use `sanitizeHTML()` for rich text editors (Quill)

**Old Method Names (DEPRECATED - DO NOT USE):**
- `filterString()` → use `sanitizeText()` instead
- `filterHTML()` → use `sanitizeHTML()` instead
- `filterSanitizeString()` → use `sanitizeText()` for most cases

---

## Code Standards

### Database Access
Expand Down
6 changes: 3 additions & 3 deletions src/Checkin.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
<div class="alert alert-info d-flex justify-content-between align-items-center mb-3">
<span>
<i class="fas fa-calendar-check mr-2"></i>
<strong><?= gettext('Event') ?>:</strong> <?= htmlspecialchars($event->getTitle()) ?>
<strong><?= gettext('Event') ?>:</strong> <?= InputUtils::escapeHTML($event->getTitle()) ?>
<span class="text-muted">(<?= $event->getStart('M j, Y') ?>)</span>
</span>
<div>
Expand Down Expand Up @@ -138,7 +138,7 @@
<option value="0"><?= gettext('All Event Types') ?></option>
<?php foreach ($eventTypes as $type) { ?>
<option value="<?= $type->getId() ?>" <?= ($eventTypeId == $type->getId()) ? "selected" : "" ?>>
<?= htmlspecialchars($type->getName()) ?>
<?= InputUtils::escapeHTML($type->getName()) ?>
</option>
<?php } ?>
</select>
Expand All @@ -153,7 +153,7 @@
<option value="" disabled <?= ($EventID == 0) ? "selected" : "" ?>><?= gettext('Select event') ?></option>
<?php foreach ($activeEvents as $evt) { ?>
<option value="<?= $evt->getId() ?>" <?= ($EventID == $evt->getId()) ? "selected" : "" ?>>
<?= htmlspecialchars($evt->getTitle()) ?> (<?= $evt->getStart('M j, Y') ?>)
<?= InputUtils::escapeHTML($evt->getTitle()) ?> (<?= $evt->getStart('M j, Y') ?>)
</option>
<?php } ?>
</select>
Expand Down
105 changes: 97 additions & 8 deletions src/ChurchCRM/utils/InputUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,109 @@ public static function translateSpecialCharset($string): string
return $resultString;
}

public static function filterString($sInput): string
/**
* Sanitize plain text by removing all HTML tags
* Use this for non-HTML values like names, descriptions that should never contain markup
*
* @param string $sInput Input text
* @return string Plain text with HTML tags removed
*/
public static function sanitizeText($sInput): string
{
// or use htmlspecialchars( stripslashes( ))
return strip_tags(trim($sInput));
}

public static function filterSanitizeString($sInput): string
/**
* Sanitize plain text and prepare for safe HTML display
* Removes HTML tags, whitespace, and escapes remaining special characters
* Best for: User-submitted form data that should be plain text
*
* @param string $sInput Input text to sanitize and escape
* @return string Safe plain text for HTML output
*/
public static function sanitizeAndEscapeText($sInput): string
{
return filter_var(trim($sInput), FILTER_SANITIZE_SPECIAL_CHARS);
return htmlspecialchars(strip_tags(trim($sInput)), ENT_QUOTES, 'UTF-8');
}

/**
* Sanitize rich text HTML with XSS protection using HTML Purifier
* Use this for user-provided HTML content (e.g., event descriptions from Quill editor)
*
* @param string $sInput HTML input to sanitize
* @return string Clean HTML with dangerous tags/attributes removed
*/
public static function sanitizeHTML($sInput): string
{
$sInput = trim($sInput);

if (empty($sInput)) {
return '';
}

// Configure HTML Purifier with strict XSS protection
$config = \HTMLPurifier_Config::createDefault();

// Define allowed HTML tags for safe content (rich text)
$config->set('HTML.Allowed',
'a[href],b,i,u,h1,h2,h3,h4,h5,h6,pre,address,img[src|alt|width|height],table,td,tr,ol,li,ul,p,sub,sup,s,hr,span,blockquote,div,small,big,tt,code,kbd,samp,del,ins,cite,q,br,strong,em'
);

// Block dangerous protocols: only allow safe URLs
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'ftp' => true, 'mailto' => true]);

// Disable dangerous elements that could bypass sanitization
$config->set('HTML.ForbiddenElements', ['script', 'iframe', 'embed', 'object', 'form', 'style', 'meta']);

// Disable automatic paragraph wrapping
$config->set('AutoFormat.AutoParagraph', false);

// Enable ID attributes for accessibility
$config->set('Attr.EnableID', true);

$purifier = new \HTMLPurifier($config);

return $purifier->purify($sInput);
}

public static function filterHTML($sInput): string
/**
* Escape HTML for safe display in HTML context (body content and attributes)
* Converts special characters to HTML entities: &, <, >, ", '
* Automatically handles stripslashes() for magic quotes compatibility
* Use this when outputting user/database values in HTML
*
* @param string $sInput Text to escape
* @return string HTML-escaped text safe for display
*/
public static function escapeHTML($sInput): string
{
return strip_tags(trim($sInput), self::$AllowedHTMLTags);
return htmlspecialchars(stripslashes($sInput), ENT_QUOTES, 'UTF-8');
}

/**
* Escape HTML for safe use in HTML attributes
* Alias for escapeHTML() - both use ENT_QUOTES for full safety
* Automatically handles stripslashes() for magic quotes compatibility
* Use this when outputting user/database values in HTML attributes
*
* @param string $sInput Text to escape
* @return string HTML-escaped text safe for attribute use
*/
public static function escapeAttribute($sInput): string
{
return htmlspecialchars(stripslashes($sInput), ENT_QUOTES, 'UTF-8');
}

/**
* Sanitize special characters - legacy filter
* Minimal sanitization, prefer sanitizeText() or escapeHTML() based on use case
*
* @param string $sInput Input to filter
* @return string Filtered string
*/
public static function filterSanitizeString($sInput): string
{
return filter_var(trim($sInput), FILTER_SANITIZE_SPECIAL_CHARS);
}

public static function filterChar($sInput, $size = 1): string
Expand Down Expand Up @@ -100,9 +189,9 @@ public static function legacyFilterInput($sInput, $type = 'string', $size = 1)
if (strlen($sInput) > 0) {
switch ($type) {
case 'string':
return mysqli_real_escape_string($cnInfoCentral, self::filterString($sInput));
return mysqli_real_escape_string($cnInfoCentral, self::sanitizeText($sInput));
case 'htmltext':
return mysqli_real_escape_string($cnInfoCentral, self::filterHTML($sInput));
return mysqli_real_escape_string($cnInfoCentral, self::sanitizeHTML($sInput));
case 'char':
return mysqli_real_escape_string($cnInfoCentral, self::filterChar($sInput, $size));
case 'int':
Expand Down
2 changes: 1 addition & 1 deletion src/DepositSlipEditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
</div>
<div class="mb-3">
<label for="Comment" class="form-label"><?php echo gettext('Comment'); ?>:</label>
<textarea class="form-control" name="Comment" id="Comment" rows="3" placeholder="<?= gettext('Add any additional notes about this deposit'); ?>"><?php echo htmlspecialchars($thisDeposit->getComment(), ENT_QUOTES, 'UTF-8'); ?></textarea>
<textarea class="form-control" name="Comment" id="Comment" rows="3" placeholder="<?= gettext('Add any additional notes about this deposit'); ?>"><?php echo InputUtils::escapeHTML($thisDeposit->getComment()); ?></textarea>
</div>
<?php
if ($thisDeposit->getType() == 'BankDraft' || $thisDeposit->getType() == 'CreditCard') {
Expand Down
8 changes: 4 additions & 4 deletions src/DonatedItemEditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@

<div class="form-group">
<label><?= gettext('Title') ?>:</label>
<input type="text" name="Title" id="Title" value="<?= htmlentities($sTitle) ?>" class="form-control" />
<input type="text" name="Title" id="Title" value="<?= InputUtils::escapeAttribute($sTitle) ?>" class="form-control" />
</div>

<div class="form-group">
Expand Down Expand Up @@ -285,16 +285,16 @@
<div class="col-md-6 col-md-offset-2 col-xs-12">
<div class="form-group">
<label><?= gettext('Description') ?>:</label>
<textarea name="Description" rows="5" cols="90" class="form-control"><?= htmlentities($sDescription) ?></textarea>
<textarea name="Description" rows="5" cols="90" class="form-control"><?= InputUtils::escapeAttribute($sDescription) ?></textarea>
</div>

<div class="form-group">
<label><?= gettext('Picture URL') ?>:</label>
<textarea name="PictureURL" rows="1" cols="90" class="form-control"><?= htmlentities($sPictureURL) ?></textarea>
<textarea name="PictureURL" rows="1" cols="90" class="form-control"><?= InputUtils::escapeAttribute($sPictureURL) ?></textarea>
</div>

<?php if ($sPictureURL != '') : ?>
<div class="form-group"><img src="<?= htmlentities($sPictureURL) ?>" /></div>
<div class="form-group"><img src="<?= InputUtils::escapeAttribute($sPictureURL) ?>" /></div>
<?php endif; ?>

</div>
Expand Down
6 changes: 3 additions & 3 deletions src/DonationFundEditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
if (isset($_POST['SaveChanges'])) {
for ($iFieldID = 0; $iFieldID < $donationFunds->count(); $iFieldID++) {
$donation = $donationFunds[$iFieldID];
$donation->setName(InputUtils::filterString($_POST[$iFieldID . 'name']));
$donation->setName(InputUtils::sanitizeText($_POST[$iFieldID . 'name']));
$donation->setDescription(InputUtils::legacyFilterInput($_POST[$iFieldID . 'desc']));
$donation->setActive($_POST[$iFieldID . 'active'] == 1);
if (strlen($donation->getName()) === 0) {
Expand Down Expand Up @@ -146,15 +146,15 @@ function confirmDeleteFund( Fund ) {
<tr>

<td class="TextColumn text-center">
<input type="text" name="<?= $row . 'name' ?>" value="<?= htmlentities(stripslashes($aNameFields[$row]), ENT_NOQUOTES, 'UTF-8') ?>" size="20" maxlength="30">
<input type="text" name="<?= $row . 'name' ?>" value="<?= InputUtils::escapeAttribute($aNameFields[$row]) ?>" size="20" maxlength="30">
<?php
if ($aNameErrors[$row]) {
echo '<span class="text-danger"><BR>' . gettext('You must enter a name') . ' .</span>';
} ?>
</td>

<td class="TextColumn">
<input type="text" Name="<?php echo $row . 'desc' ?>" value="<?= htmlentities(stripslashes($aDescFields[$row]), ENT_NOQUOTES, 'UTF-8') ?>" size="40" maxlength="100">
<input type="text" Name="<?php echo $row . 'desc' ?>" value="<?= InputUtils::escapeAttribute($aDescFields[$row]) ?>" size="40" maxlength="100">
</td>
<td class="TextColumn text-center text-nowrap">
<input type="radio" Name="<?= $row ?>active" value="1" <?php if ($aActiveFields[$row]) {
Expand Down
18 changes: 9 additions & 9 deletions src/EventEditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,8 @@
$event
->setType(InputUtils::legacyFilterInput($iTypeID))
->setTitle(InputUtils::legacyFilterInput($sEventTitle))
->setDesc(InputUtils::filterHTML($sEventDesc))
->setText(InputUtils::filterHTML($sEventText))
->setDesc(InputUtils::sanitizeHTML($sEventDesc))
->setText(InputUtils::sanitizeHTML($sEventText))
->setStart(InputUtils::legacyFilterInput($sEventStart))
->setEnd(InputUtils::legacyFilterInput($sEventEnd))
->setInActive(InputUtils::legacyFilterInput($iEventStatus));
Expand All @@ -354,8 +354,8 @@
$event
->setType(InputUtils::legacyFilterInput($iTypeID))
->setTitle(InputUtils::legacyFilterInput($sEventTitle))
->setDesc(InputUtils::filterHTML($sEventDesc))
->setText(InputUtils::filterHTML($sEventText))
->setDesc(InputUtils::sanitizeHTML($sEventDesc))
->setText(InputUtils::sanitizeHTML($sEventText))
->setStart(InputUtils::legacyFilterInput($sEventStart))
->setEnd(InputUtils::legacyFilterInput($sEventEnd))
->setInActive(InputUtils::legacyFilterInput($iEventStatus));
Expand Down Expand Up @@ -405,7 +405,7 @@

<div class='card'>
<div class='card-header'>
<h3 class="mb-0"><?= ($EventExists === 0) ? gettext('Create a new Event') : gettext('Editing Event') . ': ' . htmlspecialchars($sEventTitle ?: 'ID ' . $iEventID) ?></h3>
<h3 class="mb-0"><?= ($EventExists === 0) ? gettext('Create a new Event') : gettext('Editing Event') . ': ' . InputUtils::escapeHTML($sEventTitle ?: 'ID ' . $iEventID) ?></h3>
<?php if ($iErrors > 0): ?>
<div class="alert alert-danger mt-2 mb-0"><?= gettext('There were ') . $iErrors . gettext(' errors. Please see below') ?></div>
<?php endif; ?>
Expand Down Expand Up @@ -453,13 +453,13 @@
<td colspan="3" class="TextColumn">
<input type="hidden" name="EventTypeName" value="<?= ($sTypeName) ?>">
<input type="hidden" name="EventTypeID" value="<?= ($iTypeID) ?>">
<span class="badge badge-info font-weight-normal" style="font-size: 1rem;"><?= htmlspecialchars($sTypeName) ?></span>
<span class="badge badge-info font-weight-normal" style="font-size: 1rem;"><?= InputUtils::escapeHTML($sTypeName) ?></span>
</td>
</tr>
<tr>
<td class="LabelColumn"><span class="text-danger">*</span><?= gettext('Event Title') ?></td>
<td colspan="3" class="TextColumn">
<input type="text" name="EventTitle" value="<?= htmlspecialchars($sEventTitle) ?>" maxlength="100" class="form-control" placeholder="<?= gettext('Enter event title...') ?>" required>
<input type="text" name="EventTitle" value="<?= InputUtils::escapeHTML($sEventTitle) ?>" maxlength="100" class="form-control" placeholder="<?= gettext('Enter event title...') ?>" required>
</td>
</tr>
<tr>
Expand Down Expand Up @@ -501,7 +501,7 @@
class="form-control attendance-count <?= $isTotal ? 'total-count' : 'addend-count' ?>"
<?= $isTotal ? 'readonly' : '' ?>
min="0"
data-count-name="<?= htmlspecialchars($countName) ?>">
data-count-name="<?= InputUtils::escapeHTML($countName) ?>">
<input type="hidden" name="EventCountID[]" value="<?= $aCountID[$c] ?>">
<input type="hidden" name="EventCountName[]" value="<?= $countName ?>">
</div>
Expand All @@ -510,7 +510,7 @@ class="form-control attendance-count <?= $isTotal ? 'total-count' : 'addend-coun
</div>
<div class="form-group mt-3">
<label for="EventCountNotes" class="font-weight-bold"><?= gettext('Attendance Notes') ?></label>
<input type="text" id="EventCountNotes" name="EventCountNotes" value="<?= htmlspecialchars($sCountNotes) ?>" class="form-control" placeholder="<?= gettext('Optional notes about attendance...') ?>">
<input type="text" id="EventCountNotes" name="EventCountNotes" value="<?= InputUtils::escapeHTML($sCountNotes) ?>" class="form-control" placeholder="<?= gettext('Optional notes about attendance...') ?>">
</div>
<?php
} ?>
Expand Down
4 changes: 2 additions & 2 deletions src/EventNames.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,12 @@
for ($row = 1; $row <= $numRows; $row++) {
?>
<tr>
<td><strong><?= htmlspecialchars($aTypeName[$row]) ?></strong></td>
<td><strong><?= InputUtils::escapeHTML($aTypeName[$row]) ?></strong></td>
<td><?= $recur[$row] ?></td>
<td><?= $aDefStartTime[$row] ?: '<span class="text-muted">—</span>' ?></td>
<td>
<?php if (!empty($cCountList[$row])): ?>
<?= htmlspecialchars($cCountList[$row]) ?>
<?= InputUtils::escapeHTML($cCountList[$row]) ?>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
Expand Down
Loading
Loading