Penetration Test Remediation Response

Report Reference: AD24502-RPT-01 โ€“ Eatyourpeas Ltd โ€“ Web Application Testing v1.0
Testing Dates: 16โ€“19 March 2026
Test Conducted By: CyberLab (David Buckley, Senior Security Consultant / Cyber Scheme Team Leader โ€“ Applications)
Original Report: AD24502-RPT-01 (PDF)
Response Author: CTO
Response Date: 25 March 2026
Status: โœ… All 11 findings remediated or accepted with documented rationale


Summary

An independent web application penetration test was conducted by CyberLab against the CheckTick platform. Eleven findings were identified:

Ref Severity CVSS Title
AD1 HIGH 7.0 Stored Cross-Site Scripting
AD2 MEDIUM 6.0 Web Application Component Vulnerabilities
AD3 MEDIUM 4.0 User Account Enumeration
AD4 LOW 3.0 API Rate Limiting
AD5 LOW 3.0 CSS Injection
AD6 LOW 3.0 Ineffective Account Lockout
AD7 LOW 3.0 Information Disclosure Through API Content
AD8 LOW 3.0 Lack of Two-Factor Authentication on API
AD9 LOW 3.0 Unfiltered User Input
AD10 LOW 3.0 Weak Content Security Policy
AD11 LOW 1.0 Admin Panel Accessible

All findings have been remediated. This document provides the evidence chain for each finding in accordance with our Security Remediation Process.


AD1 โ€“ Stored Cross-Site Scripting

Severity: HIGH (CVSS 7.0)
Issue Cause: Web Development
Areas identified: Survey question builder (/surveys/<slug>/builder/...); survey take/response history view; question flow diagram

Specific vectors identified by CyberLab:

  • Stored XSS via survey question text field (</script><script>alert(...)) triggering in the question flow diagram and within the question itself.
  • Stored XSS via survey response parameter (q_<id>) where </script> break-out executed stored answers in response history.
  • HTML attribute injection via "><img src=x onerror=alert(71)> in question text.

Remediation Actions

Question builder โ€” server-side input sanitization:

_parse_builder_question_form() in checktick_app/surveys/views.py applies Django's strip_tags() to all question text and option text before saving. This removes all HTML tags at the point of storage, so no executable payload can be stored in the first place.

text = _strip_tags((data.get("text") or "").strip())
opt_text = _strip_tags(opt_text)

Survey take form โ€” output encoding:

Saved partial answers (previously stored answers displayed on reload) are injected into the page using Django's json_script template filter, which Unicode-escapes <, >, &, and ' inside the JSON payload โ€” preventing </script> break-out from a <script> block. Branching configuration is passed to the page via a data- attribute, which Django's template auto-escaping makes safe for HTML attributes.

Builder JSON payload โ€” Unicode escape:

When serializing question data for the builder UI, the JSON is encoded with explicit Unicode escaping of <, >, and & characters (.replace("<", "\\u003c") etc.) before being embedded in the HTML, preventing tag injection via the JSON payload.

Content Security Policy (nonce-based):

A per-request nonce is injected by django-csp middleware into every script-src directive. Any injected script without a matching nonce is blocked by the browser as a defence-in-depth layer. See AD10 for the full CSP configuration.

Tests in Place

checktick_app/surveys/tests/test_xss_survey_take.py exercises the public-facing survey forms with common XSS payloads (<script>alert('xss')</script>, attribute break-out strings, </script> break-out, single/double-quote injection) and asserts that the raw <script> opening tag is never present in the response body.

Evidence

Item Location
strip_tags on question input checktick_app/surveys/views.py โ€” _parse_builder_question_form()
json_script filter for answers checktick_app/surveys/templates/
Builder JSON Unicode escaping checktick_app/surveys/views.py โ€” _prepare_question_rendering()
XSS regression tests checktick_app/surveys/tests/test_xss_survey_take.py

AD2 โ€“ Web Application Component Vulnerabilities

Severity: MEDIUM (CVSS 6.0)
Issue Cause: Patching
Component identified: Bootstrap 3.4.1 (bundled by swagger-ui-dist)

Remediation Actions

swagger-ui-dist (v5.17.14) was removed entirely on 20 March 2026. Swagger UI bundled Bootstrap 3.x via SwaggerUIStandalonePreset, which contains multiple known CVEs for XSS and prototype pollution. The /api/docs route has been removed. The interactive API documentation endpoint is now /api/redoc, served using a self-hosted build of ReDoc 2.1.5 โ€” which is Bootstrap-free.

The ReDoc standalone script (redoc.standalone.min.js) is self-hosted at checktick_app/static/js/redoc.standalone.min.js. It was sourced via npm pack redoc@2.1.5 for reproducibility and is served with an SHA-384 Sub-Resource Integrity (SRI) hash. The original ReDoc CDN template included a call to fonts.googleapis.com (Google Fonts); this has been removed to eliminate third-party data exfiltration of visitor IP addresses.

Ongoing dependency scanning via pip-audit (run on every push, PR, and daily at 06:00 UTC) enforces a zero-exception policy โ€” any new vulnerability blocks deployment.

Evidence

Item Location
Swagger removal / ReDoc migration docs/compliance/vulnerability-patch-log.md โ€” swagger-ui-dist removed 20/03/2026
ReDoc self-hosted script checktick_app/static/js/redoc.standalone.min.js
ReDoc view checktick_app/api/views.py โ€” redoc_ui()

AD3 โ€“ User Account Enumeration

Severity: MEDIUM (CVSS 4.0)
Issue Cause: Web Development
Vectors identified by CyberLab:

  • Password reset: valid account responded in ~300 ms; invalid account in ~50 ms (timing side-channel).
  • Sign-up: explicit "account already exists" error message disclosed account existence.

Remediation Actions

Timing normalisation (password reset / org setup):

The organisation setup flow now unconditionally calls Django's make_password() before returning any response, regardless of whether the submitted email matches an existing account. This ensures both the "known email" and "unknown email" paths perform equivalent computational work and return comparable response times.

# Always run the default password hasher so the response time is the same
# whether or not the email corresponds to an existing account.
make_password(password)

Django's built-in PasswordResetView already returns the same "done" redirect for both known and unknown email addresses โ€” no customisation was needed.

Uniform response messages (sign-up):

The sign-up flow no longer returns a message distinguishing between "email already registered" and "new account created". Both paths return a generic, identical response. The error message "Invalid email or password. Please try again." does not reveal account existence.

Tests in Place

checktick_app/core/tests/test_password_reset_flow.py verifies the full password reset flow confirms a 302 redirect to password_reset_done is returned regardless of whether the submitted email exists.

Evidence

Item Location
Timing-safe make_password call checktick_app/core/views.py โ€” org_setup view
Uniform error message checktick_app/core/views.py โ€” "Invalid email or password. Please try again."
Password reset test checktick_app/core/tests/test_password_reset_flow.py

AD4 โ€“ API Rate Limiting

Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: Excessive GET and POST requests could be submitted to the API without throttling, enabling enumeration or denial-of-service.

Remediation Actions

Django REST Framework global throttle:

Throttle classes and rates have been applied globally to all API endpoints:

DEFAULT_THROTTLE_CLASSES = [
    "rest_framework.throttling.AnonRateThrottle",
    "rest_framework.throttling.UserRateThrottle",
]
DEFAULT_THROTTLE_RATES = {
    "anon": "60/minute",
    "user": "120/minute",
}

Anonymous requests are limited to 60/minute and authenticated requests to 120/minute. Exceeding these limits returns HTTP 429.

View-level rate limits:

Sensitive views additionally carry @ratelimit decorators (from django-ratelimit) at tighter thresholds. For example, the survey dashboard is decorated @ratelimit(key="user", rate="100/h", block=True) and the survey detail view at @ratelimit(key="ip", rate="10/m", block=True).

Dataset API โ€” read-only:

DataSetViewSet uses viewsets.ReadOnlyModelViewSet โ€” only GET requests are accepted. All write operations (POST, PUT, PATCH, DELETE) have been removed from the API surface.

Evidence

Item Location
DRF throttle configuration checktick_app/settings.py โ€” DEFAULT_THROTTLE_CLASSES, DEFAULT_THROTTLE_RATES
View-level rate limiting decorators checktick_app/surveys/views.py โ€” @ratelimit decorators
Read-only dataset viewset checktick_app/api/views.py โ€” DataSetViewSet(viewsets.ReadOnlyModelViewSet)

AD5 โ€“ CSS Injection

Severity: LOW (CVSS 3.0)
Issue Cause: Web Development
Issue: Users could supply a URL to an attacker-controlled external stylesheet. The browser would fetch and apply it, allowing visual manipulation and tracking of page visitors via Burp Collaborator-style callbacks (tracking pixel via background-image: url(...)).

Remediation Actions

Server-side CSS value sanitization:

All user-supplied CSS custom-property values (theme colours, spacing, etc.) pass through _sanitize_css_value() in checktick_app/core/theme_utils.py, which applies a strict character allowlist and rejects any value that could introduce a url() reference to an external resource. The sanitize_css_block() function applies the same logic to entire CSS blocks submitted via the per-survey theme editor.

Font-family values are further validated by sanitize_font_family(), which restricts input to letters, digits, spaces, hyphens, and commas โ€” preventing injection of url() or other unsafe constructs.

CSP style-src blocks external stylesheet loads:

The style-src directive in the Content Security Policy now explicitly lists only trusted origins:

style-src: 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.hcaptcha.com

Any stylesheet load from an attacker-controlled domain (e.g. https://evil.example.com/track.css) is rejected by the browser before the request is made. The CSP is applied globally by django-csp middleware.

font_css_url โ€” admin/owner controlled only:

The font_css_url field (used to load Google Fonts or self-hosted font stylesheets) is stored as a URLField in SiteSettings, writable only by platform administrators via an authenticated admin interface. Per-survey font_css_url overrides in the survey style JSON are set only by the survey's own creator/admin. The value is output into the <link href="..."> attribute, which is auto-escaped by Django's template engine, preventing injection of JavaScript or event handlers.

Evidence

Item Location
CSS value sanitizer checktick_app/core/theme_utils.py โ€” _sanitize_css_value(), sanitize_font_family(), sanitize_css_block()
CSS block sanitization on read checktick_app/surveys/views.py โ€” _sanitize_css applied to theme_css_light/theme_css_dark
CSP style-src restriction checktick_app/settings.py โ€” CONTENT_SECURITY_POLICY

AD6 โ€“ Ineffective Account Lockout

Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: Despite presenting a lockout message, the login endpoint could still be brute-forced (the lockout was bypassable). The JWT token endpoint /api/token was also brute-forceable and would issue bearer tokens after repeated attempts.

Remediation Actions

django-axes upgraded and reconfigured (v8.1.0):

django-axes is installed as both a Django app and middleware, placed before AuthenticationMiddleware so it intercepts requests before Django's own auth layer. The configuration:

AXES_FAILURE_LIMIT = 5          # Lock after 5 consecutive failures
AXES_COOLOFF_TIME = 1           # 1-hour cooldown
AXES_LOCKOUT_PARAMETERS = [["username"], ["ip_address"]]
  • [["username"]] locks a specific email address after 5 failures, regardless of the attacker's IP โ€” preventing bypass via IP rotation/VPN cycling.
  • [["ip_address"]] independently rate-limits credential-stuffing from a single IP even across different usernames.

When triggered, the user sees 403_lockout.html and receives an automated email notification (checktick_app/core/signals.py โ€” send_lockout_notification()).

JWT token endpoint removed:

The /api/token JWT issuing endpoint no longer exists. It has been removed from checktick_app/api/urls.py. The API now uses dedicated API keys (ct_live_* prefix) that are issued via an authenticated, MFA-protected session. Bearer token brute-forcing against /api/token is no longer possible as the endpoint does not exist.

Manual Verification Required (see Outstanding Actions below)

Evidence

Item Location
Axes configuration checktick_app/settings.py โ€” AXES_FAILURE_LIMIT, AXES_COOLOFF_TIME, AXES_LOCKOUT_PARAMETERS
Lockout signal / email checktick_app/core/signals.py โ€” send_lockout_notification()
Lockout template checktick_app/core/templates/403_lockout.html
/api/token removal confirmed checktick_app/api/urls.py โ€” router has no token or obtain_token entry
Library upgrade docs/compliance/vulnerability-patch-log.md โ€” django-axes 6.5.2 โ†’ 8.1.0, 04/02/2026

AD7 โ€“ Information Disclosure Through API Content

Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: Any authenticated user could call /api/survey-memberships/ and /api/org-memberships/ to enumerate other organisations, users, and surveys across the platform.

Remediation Actions

Membership API endpoints removed:

The /api/survey-memberships/ and /api/org-memberships/ endpoints no longer exist. The API router (checktick_app/api/urls.py) now registers only three viewsets:

  • surveys โ€” SurveyViewSet
  • datasets โ€” DataSetViewSet
  • question-group-templates โ€” PublishedQuestionGroupViewSet

No membership, user, or organisation listing endpoints are exposed through the API.

Row-level access control on remaining endpoints:

All remaining API viewsets apply permission classes and object-level filtering that restrict each user to data within their own organisation. The DataSetViewSet.get_queryset() filters to global datasets plus datasets belonging to the requesting user's organisations. SurveyViewSet permissions mirror the server-rendered permission system (can_view_survey, can_edit_survey).

API authentication restricted to API keys:

The API no longer accepts session cookies for data endpoints. API key holders must be organisation admins or creators (IsOrgAdminOrCreator permission class), preventing low-privilege users from accessing the API at all.

Evidence

Item Location
API router (membership endpoints absent) checktick_app/api/urls.py
Dataset queryset filtering checktick_app/api/views.py โ€” DataSetViewSet.get_queryset()
Permission class checktick_app/api/views.py โ€” IsOrgAdminOrCreator

AD8 โ€“ Lack of Two-Factor Authentication on API

Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: The /api/token endpoint issued bearer tokens without requiring MFA, even for users who had MFA enabled on the web application. Obtaining a session cookie and then requesting a token bypassed MFA entirely.

Remediation Actions

JWT token endpoint removed:

The /api/token JWT issuing endpoint has been removed. It is not present in checktick_app/api/urls.py. There is no longer any mechanism for obtaining a reusable bearer token by submitting username/password credentials to the API.

API key issuance flow enforces MFA:

API keys (ct_live_*) are issued via the authenticated web application user interface. A user must complete a full authenticated session โ€” which includes MFA where enabled โ€” before they can navigate to the API key management page and generate a key. The key itself is scoped to organisation admins only (IsOrgAdminOrCreator).

No session-based API access for data endpoints:

SessionAuthentication remains in the auth chain for browser-based schema/ReDoc access but does not grant access to data-bearing API endpoints, which require a valid ct_live_* API key.

Evidence

Item Location
/api/token endpoint absence checktick_app/api/urls.py โ€” no token path
API key authentication class checktick_app/api/authentication.py
Key issued via authenticated UI only checktick_app/core/models.py โ€” UserAPIKey

AD9 โ€“ Unfiltered User Input

Severity: LOW (CVSS 3.0)
Issue Cause: Web Development
Issue: HTML could be entered in the survey title field and was then rendered as a clickable link in invitation emails. The API also accepted and returned unsanitised input.

Remediation Actions

Survey question text and options โ€” strip_tags at write time:

_parse_builder_question_form() applies Django's strip_tags() to all question text, option labels, and option values before they are stored in the database. This prevents HTML from being persisted in question fields.

Survey name field โ€” auto-escaping in emails:

Django's template auto-escaping is applied throughout all email templates. Where {{ survey_name }} or {{ survey.name }} appears in an email template without the |safe filter, HTML characters are escaped (e.g. <a href="..."> becomes &lt;a href="..."&gt;). The email templates do not use |safe on user-supplied fields.

Email subjects use plain-text Python f-strings (f"You're invited to complete: {survey.name}") โ€” email subjects are plain text and cannot render HTML regardless of content.

Question group names โ€” strip_tags at write time:

Group creation and update views apply strip_tags() to group names and descriptions before saving:

name = strip_tags(raw_name).strip() or group.name
group.description = strip_tags(raw_desc).strip() if raw_desc else group.description

API โ€” input validation via DRF serializers:

API endpoints use DRF serializers that validate field types and formats. Write operations have been removed from the dataset API (read-only viewset); no unvalidated free-text is accepted and echoed back by the current API surface.

Evidence

Item Location
strip_tags on question text/options checktick_app/surveys/views.py โ€” _parse_builder_question_form()
strip_tags on group names checktick_app/surveys/views.py โ€” group create/update views
Email templates auto-escaping checktick_app/core/templates/emails/ โ€” no |safe on user fields

AD10 โ€“ Weak Content Security Policy

Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue at test time: The CSP at the time of testing contained https://unpkg.com in script-src (a known CDN with JSONP bypass potential), lacked a base-uri directive, lacked require-trusted-types-for, and did not use 'strict-dynamic'.

Remediation Actions

django-csp upgraded to 4.0 and CSP rewritten:

The library was upgraded from 3.8 to 4.0 on 04/02/2026 with a full rewrite of the directives:

CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": ("'self'",),
        "script-src": (
            "'self'",
            "https://cdn.jsdelivr.net",
            "https://js.hcaptcha.com",
            "https://*.hcaptcha.com",
            "'sha256-VgCjwSQKrGh+sHG4BrL4j+YYXbk1jJ67Fhx/zOWV9Qg='",
            "'sha256-Ern26/V1en8rbpKeo11EANZya/3kzsTaYj+q75D787M='",
            CSP_NONCE,  # Per-request nonce
        ),
        "style-src": ("'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://*.hcaptcha.com"),
        "font-src": ("'self'", "https://fonts.gstatic.com", "data:"),
        "img-src": ("'self'", "data:"),
        "connect-src": ("'self'", "https://hcaptcha.com", "https://*.hcaptcha.com"),
        "frame-src": ("'self'", "https://hcaptcha.com", "https://*.hcaptcha.com"),
        "frame-ancestors": ("'self'",),
    },
}

Changes from the tested policy:

  • https://unpkg.com has been removed from script-src (was flagged as a known JSONP bypass vector).
  • frame-ancestors: 'self' is present โ€” prevents clickjacking.
  • Per-request nonces are in use โ€” no injected script without a matching nonce will execute.
  • All hashes for hCaptcha inline scripts are explicitly listed rather than relying on 'unsafe-inline' for scripts.

Residual items and accepted risk:

Item Status Notes
cdn.jsdelivr.net in script-src Accepted โ€” mitigated Used for SortableJS. unpkg.com removed. The nonce requirement means any JSONP bypass would require the attacker to also control a nonce-bearing <script> tag, which is not achievable without a stored XSS (which is separately remediated).
Missing base-uri โœ… Added โ€” 25/03/2026 base-uri: 'self' added to CONTENT_SECURITY_POLICY directives in checktick_app/settings.py.
Missing require-trusted-types-for 'script' Accepted for now Requires refactoring all DOM manipulation. Logged as a future hardening item.
style-src 'unsafe-inline' Accepted Required by hCaptcha widget and DaisyUI. Does not affect script execution.

Evidence

Item Location
CSP configuration checktick_app/settings.py โ€” CONTENT_SECURITY_POLICY
CSPMiddleware checktick_app/settings.py โ€” MIDDLEWARE list
Library upgrade docs/compliance/vulnerability-patch-log.md โ€” django-csp 3.8 โ†’ 4.0, 04/02/2026

AD11 โ€“ Admin Panel Accessible

Severity: LOW (CVSS 1.0)
Issue Cause: Configuration
Issue: The Django admin panel was accessible at the well-known default URL /admin/ from the internet, providing an unauthenticated attacker with a login form to brute-force.

Remediation Actions

Custom AdminSite returns 404 to all non-superusers:

CheckTickAdminSite in checktick_app/admin.py overrides admin_view() and login() to raise Http404 for any request from a user who is not an active superuser. This means:

  • /admin/ โ€” returns 404 to all unauthenticated and non-superuser visitors.
  • /admin/login/ โ€” returns 404 directly (the login form is not served at all).
def login(self, request, extra_context=None):
    # Return 404 rather than showing the login form to non-superusers
    raise Http404

def admin_view(self, view, cacheable=False):
    original = super().admin_view(view, cacheable)
    def inner(request, *args, **kwargs):
        if not self.has_permission(request):
            raise Http404
        return original(request, *args, **kwargs)
    return inner

This effectively removes the admin from an attacker's perspective โ€” it returns 404 rather than a redirect to a login page, preventing enumeration of the admin URL and eliminating the brute-force login surface. The admin remains accessible to authenticated superusers who navigate directly to it.

Admin URL moved to non-default path (25/03/2026):

The admin URL has been changed from the well-known default /admin/ to /ct-admin/ in checktick_app/urls.py. Automated scanners probing for Django admin panels at the default path will receive 404 responses. IP-level restriction remains enforced at the platform ingress (Northflank reverse proxy) for the production environment.

Evidence

Item Location
Custom AdminSite (404 for non-superusers) checktick_app/admin.py โ€” CheckTickAdminSite
CheckTickAdminConfig in INSTALLED_APPS checktick_app/settings.py
Admin URL path changed to /ct-admin/ checktick_app/urls.py โ€” path("ct-admin/", admin.site.urls)

Remediation Summary Table

Ref Severity Title Status Remediation Date
AD1 HIGH Stored Cross-Site Scripting โœ… Remediated March 2026
AD2 MEDIUM Web Application Component Vulnerabilities โœ… Remediated March 2026
AD3 MEDIUM User Account Enumeration โœ… Remediated February 2026
AD4 LOW API Rate Limiting โœ… Remediated February 2026
AD5 LOW CSS Injection โœ… Remediated March 2026
AD6 LOW Ineffective Account Lockout โœ… Remediated (manual verification pending) February 2026
AD7 LOW Information Disclosure Through API Content โœ… Remediated March 2026
AD8 LOW Lack of Two-Factor Authentication on API โœ… Remediated March 2026
AD9 LOW Unfiltered User Input โœ… Remediated March 2026
AD10 LOW Weak Content Security Policy โœ… Remediated โ€” 1 item accepted (Trusted Types) March 2026
AD11 LOW Admin Panel Accessible โœ… Remediated March 2026

Outstanding Actions

AD6 โ€“ Manual Lockout End-to-End Verification (Required)

The django-axes configuration is in place and has been code-reviewed. The lockout behaviour should be verified with a manual end-to-end test in the staging environment before being formally closed.

Pre-conditions for a valid test:

  • Must be run against the staging (or production) environment, not localhost/dev.
  • AXES_IP_WHITELIST includes 127.0.0.1 and localhost with AXES_NEVER_LOCKOUT_WHITELIST = True, so axes intentionally never locks out local IP addresses. Testing from localhost will produce no lockout and no email.
  • Requests must be spaced โ‰ฅ 15 seconds apart.
  • The nginx auth rate-limit zone is 5r/m burst=3 nodelay on all /(accounts|login|...) paths. Rapid successive submissions are rejected with HTTP 429 by nginx before Django sees them โ€” axes never records the failure and no email is sent. Space each attempt by at least 15 seconds so nginx passes each request through.

Test procedure:

  1. Use a test account with a known password against the staging URL.
  2. Submit a failed login attempt (wrong password). Wait 15 seconds.
  3. Repeat step 2 a total of 5 times.
  4. On the 6th attempt: confirm the response is the 403_lockout.html lockout page (HTTP 403 from Django, not HTTP 429 from nginx โ€” if you see 429 you are submitting too fast).
  5. Confirm the lockout notification email arrives in the test account's inbox.
  6. Repeat from a different IP/VPN using the same email โ€” confirm the account remains locked (username-based lock is IP-independent).
  7. Wait for the 1-hour cooldown (or clear via axes.utils.reset_request() in a shell) and confirm normal login is restored.
Test Date Tester Result Notes

AD10 โ€“ Accepted Risk (Remaining)

  • ~~Add base-uri: 'self' to CSP directives.~~ โ€” Done 25/03/2026
  • Evaluate require-trusted-types-for 'script' adoption โ€” accepted as a long-term item; requires DOM manipulation refactor.

Approvals

Role Name Date Signature
CTO 25/03/2026
SIRO/DPO

This document forms part of the CheckTick Security Remediation Evidence Chain and should be retained for a minimum of 3 years in accordance with our Data Retention Policy.