⚠ You are offline. Changes cannot be saved until your connection is restored.
Site-Wide Security
Data Protection
- Database queries use parameterized statements (
SqlParameter bindings through a centralized DatabaseHelper), which prevents SQL injection attacks.
- User-provided content is HTML-encoded at render time before being displayed, preventing cross-site scripting (XSS) attacks.
- Server-side validation is performed on all inputs independently of any client-side validation.
- Query-string IDs are parsed through
BasePage.TryGetQueryStringInt() — non-numeric or negative values fall back to a safe default before being concatenated into any SQL or used to load a record.
- Dropdown values posted back to admin pages (
BulkEdit, Charts) are cast to integers before being used in queries — defense-in-depth on top of EventValidation.
Authentication & Session Management
- Authentication uses an encrypted and cryptographically signed Forms Authentication ticket (
protection="All"): the cookie payload is both AES-encrypted (its contents cannot be inspected) and HMAC-signed (a single-byte modification invalidates the entire ticket).
- If your session data is lost (for example, after the server’s application pool recycles), it is automatically rebuilt from your valid authentication ticket in
Global.asax.Application_AcquireRequestState — you remain logged in without re-entering your credentials. The rebuild runs before the page lifecycle starts, so every request type (pages, AJAX page methods, .ashx handlers) benefits from the same recovery.
- Sessions use non-default cookie names (
QFITSESSION for the session ID, qFITV2AUTH for the auth ticket) to reduce automated targeting.
- The authentication ticket is decrypted inside an error-handling block — malformed or tampered cookies are silently discarded rather than causing an application error.
- When the session is rebuilt from a ticket, the account’s
Active and Approved flags are re-checked against the database. A revoked account is signed out at that moment regardless of how long the ticket is still valid for.
Access Control
- Authorization is verified independently on every sensitive operation, not just when a page first loads.
- Authentication state is re-evaluated on every page load and every AJAX call. There is no endpoint that trusts a check performed during a prior request.
- State-changing handlers (save/delete buttons) call
BasePage.VerifyAuthenticatedOrShow() as a defense-in-depth re-check — if the session has expired mid-edit, the user sees a clear “session expired” message without losing their form input.
- Per-record ownership is enforced at the database stored-procedure layer: the proc requires a UserID argument and filters every UPDATE or DELETE on that ID, so a bug in application-layer code could not cause one user’s record to be modified by another.
Security Headers
The following security headers are sent with every response:
- X-Content-Type-Options: nosniff — prevents browsers from guessing content types, stopping MIME-sniffing attacks that could cause a non-HTML response to be interpreted and executed as a script.
- X-Frame-Options: SAMEORIGIN — prevents the site from being embedded in iframes hosted on other origins, protecting against clickjacking and UI-redress attacks.
- Referrer-Policy: strict-origin-when-cross-origin — sends only the origin (not the full URL) to third-party sites when you follow a link, preventing path and query-string data from leaking to external destinations.
- Permissions-Policy — explicitly disables browser APIs the application does not use (geolocation, microphone, camera, payment, USB, magnetometer, gyroscope, accelerometer, interest-cohort, browsing-topics). Even a fully compromised script running on the page cannot prompt for access to these capabilities.
- Strict-Transport-Security (HSTS): max-age=31536000; includeSubDomains — instructs browsers to use HTTPS exclusively for one full year, covering the qFITpro.com domain and every subdomain. Once received, the browser refuses plain-HTTP downgrades even if a user attempts to type an
http:// URL.
- The HSTS header is applied conditionally — only on responses served over a verified HTTPS connection (including X-Forwarded-Proto from the Plesk SSL terminator), and never on localhost requests. This prevents a developer’s local environment from being permanently locked to HTTPS-only.
- Content-Security-Policy (CSP) — restricts where browser content can be loaded from.
default-src 'self' blocks all third-party origins by default; connect-src 'self' blocks cross-origin AJAX/fetch calls; frame-ancestors 'self' reinforces the X-Frame-Options block at the modern-CSP layer; form-action 'self' prevents any form on the site from being repointed to submit user data to an external domain; base-uri 'self' prevents a <base> tag from being injected to relocate every relative URL; and object-src 'none' blocks embedding plugin objects altogether.
- Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Resource-Policy: same-origin isolate the document from cross-origin window references and prevent its resources from being embedded by other origins.
- The
X-Powered-By, X-AspNet-Version, and Server response headers are removed from every response, preventing automated scanners from identifying the server technology stack.
- Security headers are emitted by two independent mechanisms: the IIS
<httpProtocol> block in Web.config (for production IIS) and the Application_BeginRequest handler in Global.asax (for IIS Express and other development environments). Both layers must fail simultaneously for a header to be missing from a response.
ViewState & Form Integrity
- All ViewState is cryptographically signed (Message Authentication Code) and encrypted (
viewStateEncryptionMode="Auto"). The encrypted, MAC-protected ViewState cannot be read by an attacker and cannot be modified without invalidating the signature.
- EventValidation is enabled site-wide. The framework records the set of legitimate postback target IDs and event values when a page is rendered, and rejects any postback containing an event from a source not present in that original set.
- ViewState is bound to your current session via
ViewStateUserKey, which is set in the OnInit stage of every page — the earliest valid point in the page lifecycle. ViewState submitted in one user’s session cannot be replayed by another user, even if the raw bytes are captured.
- Request validation (
validateRequest="true") rejects any form submission containing potentially dangerous HTML or script characters before it reaches application code.
Cookie & Token Handling
- Authentication tokens and session IDs are stored in cookies only (
cookieless="UseCookies") — never placed in URLs. This prevents tokens from appearing in server logs, browser history, address-bar copies, or HTTP Referer headers when following an outbound link.
- The Forms Authentication ticket carries the UserID only. No password, role, or other sensitive credential ever appears inside the cookie. Roles and profile data are loaded from the database on each request.
- Cross-application authentication redirects are disabled (
enableCrossAppRedirects="false"), preventing authentication tickets from being accepted by other applications on the same server or domain.
- Both the session cookie (
QFITSESSION) and the authentication cookie (qFITV2AUTH) are set with SameSite=Lax. Browsers will not send these cookies on cross-site POST requests or with cross-site embedded resources, blocking a wide class of CSRF attacks at the cookie-delivery layer.
- A site-wide default in
<httpCookies> applies HttpOnly=true and SameSite=Lax to every cookie the application emits, including cookies created without an explicit policy. A developer cannot accidentally introduce a JavaScript-readable cookie by forgetting to set the flag.
- Expired session IDs are regenerated rather than reused (
regenerateExpiredSessionId="true"), preventing session fixation attacks that rely on planting a known-expired identifier and waiting for it to be reissued.
Browser Cache Control
- Every response served to an authenticated user carries a
Cache-Control: no-cache, no-store directive along with a past Expires header. This prevents browsers and intermediate proxies from caching authenticated pages, so pressing the Back button after logging out cannot reveal previously viewed account data.
- These cache directives are applied centrally in the master page’s
Page_Load handler. Every authenticated page in the application automatically receives them — a developer cannot accidentally introduce a cacheable authenticated page by forgetting to set them.
Rate Limiting
- Rate limiting is applied independently to multiple sensitive operations, each with its own attempt threshold and time window: login, forgot-password, invite sending, account sign-up (Join), and the contact form.
- All rate-limiting counters are stored in server-side memory. They cannot be reset by clearing cookies, opening a private browser window, or starting a new session.
- The contact form applies two independent rate limits simultaneously: one keyed to the submitter’s IP address, and a second keyed to their authenticated user account. A user rotating IP addresses cannot bypass the per-account limit, and a shared network cannot exhaust the per-IP limit for all of its users simultaneously.
- Throttle thresholds and time windows are configurable per operation in
Web.config (ThrottleLogin_*, ThrottlePasswordReset_*, ThrottleInvite_*, ThrottleContactUs_*, ThrottleJoin_*) — the limits can be tightened without a code deployment if abuse patterns shift.
Client IP Detection
- The IP address used for all rate-limiting decisions and audit logging is resolved in a proxy-aware manner: the
X-Forwarded-For request header is read first (with the leftmost entry taken when the value is a comma-separated chain), enabling accurate client attribution when traffic passes through the Plesk SSL terminator or any other reverse proxy. The direct connection address is used as a fallback when no forwarding header is present.
Search Engine & Crawler Controls
- Sensitive pages — including login, account, preferences, and per-user content pages — carry
noindex, nofollow directives. Search engines cannot index these pages or follow their links.
- A
robots.txt file explicitly instructs search engine crawlers to avoid account-related paths.
Error Handling
- The owner is the only audience for technical error details; end users see a friendly error page with no internal stack content unless the operator has explicitly enabled detailed errors site-wide.
- Every unhandled exception triggers an immediate email notification to the site operator, including the request URL, HTTP method, user IP address, session ID, and the full exception chain. This ensures failures are investigated before users need to report them.
- The error notification handler is itself wrapped in error handling — a failure to send the alert email cannot prevent the error page from being shown to the user.
- Every 500-level error generates a short 8-character correlation ID that is shown to the user on
Error.aspx and logged into the central plp_Logs.CorrelationID column. A user can quote the ID in a support request and the operator can grep the audit log directly to find the matching entry.
- 404 Not Found errors are treated as a distinct case: they are caught by the global error handler, logged as warnings with the requested URL and authenticated user context, and redirected to a generic error page. Internal file paths, directory structure, and the presence of protected files are never revealed in the response.
- 401 Unauthorized HTTP responses are silently promoted to 403 Forbidden and redirected to the same generic error page. This prevents an attacker from distinguishing between “resource does not exist” and “resource exists but requires authentication” — the visible result is identical in both cases.
- A loop-breaker in
Application_Error detects exceptions that occur while rendering Error.aspx itself and short-circuits the redirect, preventing an infinite 302 chain on a misconfigured production environment.
Security Logging
- Security-relevant events are logged to the shared
plp_Logs table (with App = "qFIT" so the cross-app log can be filtered down) along with IP address, browser information, and timestamp. Distinct severity levels (INFO, WARN, ERROR, SECURITY, AUDIT) and category tags (Login, LoginFailed, Logout, PasswordChange, PasswordReset, SecurityViolation, AccessDenied, Exception, PreferencesSaved, AccountCreated, FindAndReplace) make it possible to filter the audit trail to a specific event class.
- When you visit key pages, your IP address, browser, device type, OS, screen resolution, language, and time zone are recorded. This information is used to help identify and respond to unauthorized access attempts and to reconstruct incidents from the audit trail.
- Every log entry is enriched with structured fields parsed from the User-Agent — device type (mobile, tablet, desktop), browser family, and OS family/version — so the audit trail is filterable without requiring full-text User-Agent searches.
- All logging operations are wrapped in exception handlers — a failure to write a log entry cannot crash or degrade the application. The user’s operation continues even if the audit-trail database is briefly unavailable.
Maintenance Mode Gate
- The site can be taken offline for maintenance by flipping a single Web.config flag. When enabled,
Global.asax.Application_PostAuthenticateRequest redirects every request to a dedicated MaintenanceMode.aspx page that returns HTTP 503 Service Unavailable with a Retry-After hint. The gate covers every request type — pages, AJAX page methods, and HTTP handlers — not just pages that use the master.
- Static asset paths (
/Content/, /Scripts/, /Images/, favicon, manifest, service worker, offline page) are bypassed so the maintenance page itself renders with its CSS and icons. An admin allowlist (MaintenanceBypassUserIDs in Web.config) lets specific UserIDs continue to access the site during the outage for verification and rollback.
Logout & Session Termination
- Logging out is a multi-step process:
FormsAuthentication.SignOut() revokes the authentication cookie, the Remember-Me token row in dbo.LoginTokens is deleted, Session.Clear() removes all session data, and Session.Abandon() marks the server-side session for immediate destruction.
- The auth cookie (
qFITV2AUTH) and session cookie (QFITSESSION) are explicitly overwritten with empty values carrying a past expiration date. This belt-and-suspenders approach ensures client-side removal even on browsers that do not reliably honor cookie deletion via SignOut() or Abandon() alone.
- The logout event is logged — including UserID and IP address — before the session is cleared, ensuring the audit trail is never lost.
Return URL & Redirect Safety
- After login, the application redirects you to the page you were trying to reach (carried in the
go query-string parameter). Redirect targets are constrained to relative paths within the application — the login flow cannot be tricked into bouncing the user to an external domain.
- Back buttons resolve through
BasePage.ResolveBackUrl(), which refuses any value that does not start with a single / (rejecting protocol-relative // open-redirect prefixes) and does not contain a host portion.
Previous Page Tracking Safety
- The referring page is stored in the session to enable Back-button navigation. Before storing it, the referrer host is compared to the current request host — only same-origin referrers are recorded. External referrers are silently discarded, preventing navigation state from being influenced by attacker-controlled external pages.
Outbound Email Security
- All outbound email is transmitted over an encrypted SMTP connection using TLS on port 587. The
enableSsl setting in Web.config mandates encryption — plaintext delivery is not possible.
- SMTP credentials are read from
Web.config at runtime via the framework’s system.net/mailSettings section. The SmtpClient instances used in application code do not contain hardcoded credentials.
- The SMTP client timeout is explicitly capped at 15 seconds (overriding the framework default of 100 seconds). A slow or unreachable mail server cannot pin a request thread for an extended period.
- Exception notification emails include the full request URL, HTTP method, client IP address, User-Agent string, authenticated UserID, server-side session ID, and the complete exception chain (up to five levels of inner exceptions). This provides enough context to fully reconstruct an incident without requiring direct server log access.
- Exception notification emails are suppressed when the application is running on a localhost hostname. Development errors never trigger live alerts.
- Email send operations are wrapped in their own exception handlers. A mail-server outage cannot prevent the underlying user action from completing or the error page from being shown to the user.
Output Encoding Discipline
- Every user-controllable value that is rendered into HTML — usernames, Method/Target/Category names, notes fields on detail pages, the IP address shown on the login page, error messages echoed back from validation — passes through
HttpUtility.HtmlEncode() immediately before being written to the response.
- Encoding is applied at the render site, not at insertion time. Even if a value was inserted into the database before encoding was enforced at that location (for example by an older version of the code, an import script, or a developer using SSMS directly), the page rendering it still encodes the value safely.
- Hand-built HTML fragments (Checklist rows, change-log tables, contact-us submission lists) explicitly call
HttpUtility.HtmlEncode on every user-controllable substring before concatenation.
Built-in Provider Isolation
- The ASP.NET RoleManager is disabled entirely (
roleManager enabled="false"). Role decisions are made exclusively by application code reading from session data and the dbo.Logins.UserRole column — the framework’s built-in role infrastructure plays no part.
CSRF Protection for AJAX, WebMethods, and Handlers
- Every authenticated request issued by the page receives a per-session CSRF token, generated as a cryptographically random 32-character GUID and stored only in server-side session memory. The token is exposed to client JavaScript through a single inline assignment (
window.qfitCsrfToken) injected by BasePage.OnPreRender; it is never written to a cookie, never embedded in a URL, and never reflected into the DOM as an attribute.
- The shared AJAX helper (
qFIT.callMethod) reads window.qfitCsrfToken on every call and forwards it as a custom X-QFIT-CSRF request header. A Sys.Net.WebRequestManager hook also injects the same header on every classic PageMethods.* call, so the entire AJAX surface area is covered without per-page changes.
- Every state-changing WebMethod (
Checklist.SetCompleted, ToDo.SetCompleted, ContactUsStatus.GetRecord, etc.) invokes SecurityHelper.ValidateCsrfToken() as its first action — before any database query runs.
- Token comparison is performed in constant time. The validator accumulates the per-character bit-difference over the full string length, never short-circuiting on the first mismatched character. This prevents the timing side-channel that a length- or byte-position-sensitive comparison would expose.
- Cross-origin AJAX delivery of the token is impossible: the
X-QFIT-CSRF header is not a CORS-safelisted header, so any cross-origin fetch or XMLHttpRequest that attempts to set it triggers a preflight that the server’s connect-src 'self' CSP rejects.
- Every CSRF validation failure — whether the header is missing, the session token is missing, or the values do not match — is recorded as a
SecurityViolation event with the requesting UserID and the request path.
Configuration Whitelisting
- User-selectable preference values are validated server-side against an explicit allowlist before persistence:
ColorScheme must be L or D; SiteMode must be 1, 2, or 3; FontSize must be 14, 16, or 18. Any other value falls back to a safe default rather than being written through.
- The Contact Us feedback category is validated against a fixed string list (
bug, quality, enhancement, feature, security, access, support, other) before the database insert.
- Comma-separated UserID lists in
Web.config (privileged-tester exemptions, maintenance bypass) parse each token through Integer.TryParse. Non-integer tokens, negative values, and zero are silently dropped so a typo cannot accidentally widen the privileged set.
Find & Replace Hardening
- The admin
FindAndReplace page validates every table-name and column-name argument against a strict regular expression (^[A-Za-z_][A-Za-z0-9_]{0,127}$) before any SQL is constructed. A rejected identifier is logged as a security violation alongside the requesting UserID.
Login Page Security
Rate Limiting
- Login attempts from a single IP address are rate-limited. After too many failures, further attempts from that address are temporarily blocked. The threshold and window are configurable in
Web.config — they are not hardcoded.
- The rate-limit counter is stored in server-side memory — it cannot be reset by clearing browser cookies, switching browsers, or opening a new session.
Credential Protection
- Failed login attempts show the same error message regardless of whether the username or the password was incorrect. This prevents attackers from discovering which usernames exist on the platform.
- Password comparison is performed in constant time. The comparison routine accumulates the per-character bit-difference over the full string length and never short-circuits on the first mismatched character, removing the timing side-channel that a position-sensitive comparison would expose.
- The password field is never pre-populated from any stored source. The previous “u”/“p” plaintext-cookie auto-submit path was deliberately removed as part of the May 2026 security pass.
- After login, you are only redirected to relative paths within qFIT — open-redirect attacks through the login flow are not possible.
- After a successful login, the account’s
Active and Approved flags are checked against the database. An account with a correct password is still rejected if either flag is unset, so toggling either column in the operator’s admin view immediately blocks subsequent logins.
- Password fields are not trimmed before comparison — a password that begins or ends with a space is verified exactly as entered.
Session Rotation on Login
- After a successful login, the pre-login session is abandoned via
Session.Abandon() and its cookie is overwritten with an empty value bearing a past expiration date. The browser receives a fresh QFITSESSION identifier on the next request, so any pre-authentication session ID known to an attacker (for example, planted by a session-fixation link) cannot be promoted to an authenticated session.
- The Forms Authentication ticket carries the UserID only and is set with
protection="All" (encrypted + HMAC-signed). The bearer cookie cannot be inspected or modified by the client.
Remember-Me Token
- The optional “remember me” cookie (
qFIT_Remember) carries a random 64-character token. The token is hashed with SHA-256 before storage in dbo.LoginTokens — the database stores only the hash, so a database snapshot does not expose any usable bearer credential.
- Each token is single-purpose: it lets a returning browser silently re-establish an authenticated session, but never bypasses the password check on the human-typed login form. The token is regenerated on use and the underlying row is deleted on explicit logout, so a stolen device’s persistent login is revoked the next time the legitimate user signs out.
- Token rows carry an explicit
ExpiresAt column and are rejected past expiry. An abandoned device’s Remember-Me token cannot be replayed indefinitely.
Audit Logging
- Every login attempt — successful or failed — is logged with the IP address, browser, and timestamp.
- Your IP address, browser, device type, OS, screen resolution, language, and time zone are recorded when the login page loads.
- Successful login resets the IP-based throttle counter, so a legitimate user’s IP address is not blocked from future logins after a successful authentication.
Browser Integration
- The password field uses
TextMode="Password" so the entered value is masked in the browser’s rendered DOM. Shoulder-surfing of the typed password is mitigated at the input layer.
- The username and password fields carry standard HTML
autocomplete hints so modern password managers can fill credentials correctly while signalling to the browser that no other field should be confused for a password.
- The login form’s default submit button is set explicitly to the Login button so pressing Enter never accidentally triggers a different page action.
Per-Record Ownership (Checklist, Categories, Targets, Methods)
Stored-Procedure Ownership Gate
- Every save and delete on a Category, Target, or Method routes through a stored procedure (
df_qfit.UpdateqFITGroup, df_qfit.DeleteqFITGroup, and equivalents for Targets and Methods) that requires a UserID argument and includes it in the WHERE clause. Passing another user’s record ID simply matches zero rows — the database does the access-control enforcement, independent of any application-layer check.
- The application layer also enforces the same rule before issuing the call: query-string IDs are validated through
BasePage.TryGetQueryStringInt() and the current UserID is taken from the rebuilt Session rather than from any user-supplied source.
Detail Pages
- The
CategoryDetails, TargetDetails, and MethodDetails reads validate their query-string IDs and bind through SqlParameter — a non-numeric or negative ID never reaches the database.
- The “Save Notes” handlers on each Detail page (historically tagged with a SQL-injection TODO) were migrated to parameterized statements via
DatabaseHelper.ExecuteNonQuery with SqlParameter bindings.
Checklist State Changes
- The
Checklist.SetCompleted and ToDo.SetCompleted WebMethods validate the X-QFIT-CSRF header before doing any work, then verify the calling user owns the Method whose state is being toggled. A check-off on someone else’s Method is rejected at the data layer.
Admin Tools
- Admin-only pages (
Admin.aspx, FindAndReplace.aspx, ul.aspx, Backlog.aspx, Test.aspx) gate access via BasePage.RequireAdmin(). Non-admin requests receive a 403 and the attempt is audited with the requesting UserID and the requested path.
- The same pages carry
<deny users="?" /> entries in Web.config as a defense-in-depth layer, so an anonymous request never even reaches the application code that runs the role check.
Account & Session Security
Session Rebuild After Interruption
- ASP.NET InProc sessions are lost if the server’s application pool restarts. When this happens,
Global.asax.Application_AcquireRequestState detects the missing session and reconstructs it from your still-valid Forms Authentication ticket by re-querying your username, role, first name, email, and time zone from dbo.Logins LEFT JOIN dbo.BasicUserProfiles. You remain logged in and your work is not interrupted.
- The rebuild runs before the page lifecycle starts, so it benefits every request type uniformly: pages, AJAX page methods, .ashx handlers, and the master page’s own nav configuration.
- The rebuild only succeeds when a valid, non-expired, cryptographically intact authentication ticket is present and the embedded identity parses as a positive integer matching a known account that is both
Active and Approved. A missing, expired, or tampered ticket results in an anonymous session, not a reconstructed one. A revoked account is signed out at the rebuild step.
- Session rebuilds are logged at INFO level with the UserID and timestamp. Unusual patterns — such as repeated rebuilds from a single account within minutes — are visible in the audit trail for forensic review.
Preference & UI State Cookies
- Cookies used to persist UI preferences (
qfit_theme, qfit_font, qfit_sitemode, qfit_tz) carry only display telemetry — no identity, no authentication token, no permission flag. The site-wide <httpCookies> default ensures HttpOnly=true and SameSite=Lax apply to all of them.
- On a cold login (new device, fresh browser) these preference cookies are hydrated into
Session by the centralized helper, so the theme and font settings apply from the very first render. The hydration only fires when Session does not already carry the value, so a per-session change is never silently reverted to the cookie state.
Authentication Re-verification on Every Operation
- Authentication state is re-evaluated on every page load and every AJAX operation. There is no page or endpoint that relies on a check performed during a previous request.
- When a WebMethod or AJAX handler is called and the in-memory session is cold (such as after an app pool restart), the user identity is re-derived from the still-valid Forms Authentication ticket via
HttpContext.Current.User.Identity.Name. The framework populates this principal regardless of session state, so AJAX operations remain authenticated through transient infrastructure events without forcing the user to re-log in.
- The fallback only succeeds when the FormsAuth ticket is present, decryption succeeds, and the embedded identity parses as a positive integer matching a known active and approved user. A missing, expired, or tampered ticket results in an unauthenticated response, not a recovered session.
Authenticated User Resolution Order
- The shared user-resolution helper (
SecurityHelper.GetAuthenticatedUserID) follows a strict priority order: (1) read Session("UserID") if present and parses to a positive integer; (2) otherwise, read the Forms Authentication ticket and require the ticket to be present, decrypted, and to parse as a positive integer. There is no third fallback — no cookie value, no query-string parameter, no client-supplied header is consulted for identity.
- When the slow path (FormsAuth fallback) succeeds, the resolved UserID is restored into
Session("UserID") so subsequent calls within the same request use the fast path. This prevents the cold-session penalty from compounding across multiple AJAX calls in a single page-load burst.
- The fallback returns 0 (anonymous) rather than throwing when the ticket cannot be validated. Endpoints that receive 0 return the standard “session expired” response, identical to the response a never-authenticated client would receive. The two cases are indistinguishable to a probing attacker.
Anonymous Access Boundaries
- Anonymous users can view a curated set of public pages (About, Help, Tips, Walkthrough, Tutorial, Contact Us, etc.). Every other page is gated by the master page’s
PublicPages allowlist and the per-page BasePage.RequireAuthentication() check, plus an explicit <deny users="?" /> in Web.config for the sensitive ones (Preferences, Admin, Backlog, FindAndReplace, ContactUsStatus).
- The invite flow (
Invite.aspx) is open to anonymous users so word-of-mouth invites work without forcing the inviter to sign up first, but the per-IP rate limit applies regardless of authentication status, and a per-UserID limit stacks on top for authenticated users.
Preferences Persistence
- Preferences (theme, font size, site mode, time zone) save to both
Session and a long-lived cookie. The cookie acts as the durable cross-device source so the theme survives a fresh login on a new device, while the session value is the per-request authority. A tampered cookie value falls back to a safe default rather than propagating an out-of-range value into the rendered page.
Contact Form Security
Form Integrity
- The contact form is protected by the framework’s ViewState MAC + EventValidation +
ViewStateUserKey binding. Every submission is verified to originate from a page rendered for the same session — a captured form submission cannot be replayed by a different session, and a postback that fabricates events for hidden controls is rejected before any application code runs.
- Request validation (
validateRequest="true") rejects any submission containing potentially dangerous HTML or script characters at the framework boundary, before btnSubmit_Click ever runs.
Rate Limiting
- Contact form submissions are throttled simultaneously by two independent limits: one keyed to the submitter’s IP address, and a second keyed to their authenticated UserID (or the literal “anon” bucket for unauthenticated submissions). A user rotating IP addresses cannot bypass the per-account limit, and a shared network cannot exhaust the per-IP limit for all of its users simultaneously.
- The throttle thresholds and time windows are independently configurable in
Web.config (ThrottleContactUs_MaxAttempts, ThrottleContactUs_WindowMinutes), separately from the login and password-reset throttles.
- Every throttled rejection is logged as a
SecurityViolation event with the offending IP address.
Duplicate Submission Defense
- The previous submission text is held in session and compared against any new submission. A duplicate submission within the same session is rejected with a clear message, preventing accidental double-posts (such as a double-click on the Submit button while a slow network is being recovered).
File Attachment Size Limits
- Individual file size is capped at 10 MB. Total combined size across all attachments is capped at 50 MB. Both limits are enforced server-side before any file is written to disk. An oversized submission is rejected with a clear message before reaching the database.
- Uploaded filenames have the
# character stripped (which is illegal in URL paths) and are prefixed with the database-generated FeedbackID before being written to disk. Two submissions cannot collide on disk even if their original filenames were identical, and there is a stable cross-reference between the stored blob and the database record.
Input Validation
- The feedback category dropdown is validated server-side against a fixed list of known-valid values (
bug, quality, enhancement, feature, security, access, support, other) before the submission proceeds. A manipulated or unexpected category value is rejected before it reaches the database.
- The comment body is capped at 8000 characters server-side via the textbox’s
MaxLength, with the database column enforcing the upper bound independently.
- The IP address of every submitter is captured and persisted alongside the feedback record itself, providing a per-submission audit trail without depending on the central log table.
My Submissions Visibility Gate
- The
ContactUsStatus page that lets you read your own submissions filters every query on UserID = current, so a user can never load another user’s row even by manipulating the FeedbackID in the request. The same UserID filter is repeated inside the detail-loading WebMethod — the gate runs at both the page load and at every AJAX call to fetch a row.
Progressive Web App Security
Cross-Origin Request Isolation
- The qFIT service worker intercepts requests made by the app. Any request directed at a domain other than qFIT’s own origin is passed through to the network without being handled by the service worker. There is no execution path in which a cross-origin response can enter the service worker’s cache.
- The cross-origin check is performed in the first lines of the fetch handler — before strategy selection, before path matching, before any cache lookup.
HTTP Method Restriction
- The service worker only intercepts
GET requests. All other HTTP verbs (POST, PUT, DELETE, PATCH) bypass the service worker entirely and pass straight to the network. A state-changing request can never be served from cache, replayed from cache after an offline period, or cached as a side effect of a previous identical post.
API Path Exclusion
- Any request whose path begins with
/api/ is excluded from service worker handling regardless of method or origin. The namespace is intentionally reserved and inaccessible to caching, so a future API call cannot be silently replayed by the worker after an offline period.
Network-First Strategy for Data Pages
- Pages that display your Checklist, To-Do, My Week, and Dashboard use a network-first cache strategy: the live server response is always requested first, and a cached copy is used only if the network is unreachable. This ensures users always see current data rather than a stale cached snapshot, and prevents a cached page from being served across different authenticated sessions.
AJAX Responses Not Cached
- The service worker does not cache AJAX page-method responses. Sensitive JSON responses from
Checklist.SetCompleted, ToDo.SetCompleted, ContactUsStatus.GetRecord, and the rest of the page-method surface are never written to browser storage by the worker.
Static Asset Caching
- Static resources (images, fonts, icons) use a cache-first strategy with a network fallback. CSS and JavaScript files use network-first-with-cache so a fresh deploy lands immediately while still surviving an offline period.
Cache Version Management
- Each service worker deployment carries a unique cache version identifier (
CACHE_VERSION). On activation, the service worker deletes all cache buckets from prior versions. Assets from a previous deployment cannot persist after an update and cannot be served to users of the current deployment.
Offline Fallback
- When a user is offline and requests a page that is not in the cache, the service worker returns a dedicated offline page rather than allowing the browser to display a generic network error. The offline page contains no application data or authenticated content — only a status message and a reload control.
- Service worker registration is wrapped in a feature-detection check. Browsers that do not support service workers continue functioning normally over the network without any cached-content code path.
Client-Side & AJAX Defenses
Session Expiry Detection
- Every AJAX call made through the shared
qFIT.callMethod helper inspects the HTTP status. A 401 Unauthorized response (returned by the framework when the auth cookie is missing or expired) triggers a clear modal “session expired” prompt rather than failing silently.
- The prompt offers two paths: re-authenticate (the return URL is preserved so you land back where you left off) or stay on the page in view-only mode while keeping your in-progress input.
- State-changing save handlers also call
BasePage.VerifyAuthenticatedOrShow() on the server side. If the session expired between page load and form submit, the user sees the “session expired” toast and the form input is preserved — they re-authenticate via the modal and resubmit without losing their work.
Offline-Aware Saves
- Every AJAX call checks
navigator.onLine before attempting to send. If the browser is offline, the call is short-circuited locally and the supplied error handler receives an offline message immediately, without making a doomed network request that would otherwise time out.
- An
online/offline event listener toggles a CSS class on document.body and surfaces a banner. There is no scenario in which the user appears connected but their input is being lost.
Save-Button Lifecycle
- The page’s save helpers (
qFIT.setSaving, setSaved, setSaveError) drive an explicit three-state lifecycle for buttons: in-flight (disabled + spinner), saved (success indicator), and error (red flag). A reader of the page state always sees the canonical outcome rather than a stale “Saving…” spinner left behind by an interrupted handler.
Destructive-Action Confirmation
- Elements annotated with
data-confirm raise a confirmation dialog before their default action proceeds. The dialog message is taken from the attribute value, so each destructive action carries context-specific wording rather than a generic prompt. A cancelled confirmation halts the click propagation entirely, preventing the underlying delete from running.
Per-Request CSRF Header Injection
- The shared AJAX helper reads the per-session CSRF token from
window.qfitCsrfToken (injected by BasePage on every render) and sets the X-QFIT-CSRF request header on every call. Every state-changing endpoint validates this header before doing any work; an AJAX call from another tab, another origin, or a clipboard-injected snippet that does not run inside the original page cannot read the variable and therefore cannot present a valid token.
- The classic
PageMethods.* call path is also covered: a Sys.Net.WebRequestManager.add_invokingRequest hook injects the same X-QFIT-CSRF header on every PageMethods call automatically, so no page-level wiring is required.
No Sensitive Data in Local Storage
- The application does not write authentication tokens, session identifiers, email addresses, or Checklist content to
localStorage or sessionStorage. Per-page UI preference state (filter toggles, panel open/closed) is the only category of value that lives in browser storage, and each value is validated against a known set before being applied.
Request Timeout
- The shared AJAX helper sets a 30-second request timeout on every call. A hung server, broken pipe, or vanished mobile connection results in a clear error message to the user instead of an indefinitely pending request that consumes a UI slot.
Transport & Platform Hardening
Transport Layer Encryption
- The site is served exclusively over HTTPS in production. The HTTP Strict Transport Security (HSTS) header instructs every modern browser that has previously connected to the site to refuse plain-HTTP downgrades for one year, covering the apex domain and every subdomain.
- Outbound SMTP (port 587) uses the operating system’s TLS stack with current cipher suites. The application never opens a plaintext socket to an external service.
Server Fingerprint Reduction
- The IIS
X-Powered-By header, the ASP.NET X-AspNet-Version header, the Server header, and the framework version banner (enableVersionHeader="false" in <httpRuntime>) are all suppressed. Automated scanners cannot fingerprint the platform from response headers.
Layered Error Routing
- HTTP status codes 401, 403, and 404 are routed to the application’s generic
Error.aspx page via the ASP.NET <customErrors> block. The framework’s global error handler in Global.asax.Application_Error intercepts every unhandled application exception, logs the full context with an 8-character correlation ID, dispatches the operator alert email, and routes the user to the same generic page.
- The 500 entry is intentionally absent from
<customErrors> so a 500 inside Error.aspx itself doesn’t bounce back through the same redirect rule. The loop-breaker in Application_Error catches the recursive case explicitly and short-circuits.
Static Folder Authorization
- Asset folders (
Content/, Scripts/, Images/) and PWA plumbing (manifest.json, service-worker.js, offline.html) are explicitly opened to anonymous access through scoped <location> elements in Web.config. Authentication-restricted pages inherit the default deny posture by virtue of not appearing on the allowlist, and the most sensitive ones (Admin, Preferences, ContactUsStatus, etc.) carry an explicit <deny users="?" /> as defense in depth.
- Framework internals (
App_Code/, App_Data/, bin/, obj/) are not served by IIS handlers. Source code, database scripts, and compiled binaries cannot be retrieved over HTTP.
Built-in Compilation Posture
- VB.NET compilation runs with
Option Strict off but Option Explicit on. Every variable must be declared before use; implicit declaration cannot accidentally introduce a typo-driven security gap (such as a misspelled session key that resolves to Nothing and silently bypasses an access check).
- The compiler targets .NET Framework 4.7.2. The selected runtime supports the cryptographic primitives (HMAC, AES, SHA-256, PBKDF2) the application relies on for ticket protection, ViewState, Remember-Me token hashing, and (if hashing is re-enabled in the future) password storage.
Database-Layer Defenses
Parameterized Query Discipline
- Every new database call in the application flows through a single centralized access layer (
DatabaseHelper) whose every public method requires a parameterized SQL string and a SqlParameter array. There is no convenience overload that accepts a pre-built string, so there is no “shortcut” path for concatenated SQL to enter the codebase.
- The 131 ad-hoc SQL call sites that existed in older page code-behinds were migrated to
DatabaseHelper parameterized queries during the May 2026 security pass — high-risk admin import paths that previously concatenated raw form text into SQL (BodyPart, Name, Equipment) are now parameterized.
- SQL strings in application code use named placeholders (
@UserID, @FID, etc.) that are bound through SqlParameter instances. The SQL Server client driver type-checks and length-checks each parameter against the column it binds to before transmission.
- Dynamic
IN clauses (such as bulk-edit dropdowns) parse and validate every value as an integer before any value reaches the SQL string. A value that does not parse to a positive integer is dropped before query assembly.
Reader and Row Accessors
- Reader and row accessors (
GetInt, GetString, GetBool, GetDateTimeNullable) check for DBNull and missing columns before reading. A schema change that drops a column does not throw inside the access layer — it returns the documented default, preventing a partial deployment from cascading into a runtime exception.
Per-Record Ownership at the Stored-Proc Layer
- Stored procedures that mutate Categories, Targets, and Methods require a
@UserID argument and include it in every UPDATE and DELETE filter. A bug in the application-layer access check would produce zero affected rows rather than another user’s data being modified.
Connection Pool Management
- The database connection pool is sized through the connection-string options. All database commands and connections are opened and closed inside
Using blocks, guaranteeing that connections are returned to the pool immediately after use — even if an exception occurs mid-query.
- The connection string is read once at class-load time from a single
<connectionStrings> entry. There is no code path that constructs a connection string from concatenated input, so a malicious value cannot influence which database, server, or credentials are used.
Defense-in-Depth Posture
Layered Authorization
- A single state-changing Checklist or Setup operation passes through up to four independent permission checks before any data is modified: (1) the AJAX endpoint validates the X-QFIT-CSRF token; (2) it re-derives the authenticated user from session or the Forms Authentication ticket; (3) it consults the page-level
BasePage.VerifyAuthenticatedOrShow() safety net; (4) the stored procedure itself includes WHERE UserID = @UserID. Any single layer being bypassed by a future code change still leaves three independent layers in place.
- Authorization queries are constructed so that an unauthorized record ID does not produce a database error or expose row counts — the query simply returns zero rows and the operation reports a generic “not found or access denied,” identical to the response the client receives for a non-existent ID.
Layered Input Defense
- A single user-supplied string passes through up to four independent defenses before being rendered: (1) ASP.NET request validation rejects obvious script-injection patterns at the framework boundary; (2) the application caps the length to the underlying database column size via
MaxLength on the textbox plus a server-side length check; (3) the database column refuses values that exceed its declared length; (4) the rendering site calls HttpUtility.HtmlEncode immediately before writing to the response.
- A Content-Security-Policy that blocks cross-origin script execution and inline event handlers acts as the final containment layer for any value that somehow survives all four upstream checks.
Layered Transport Defense
- HTTPS exclusivity is enforced by two independent mechanisms: (1) the Plesk SSL terminator handles HTTPS at the network edge; (2) the HSTS header instructs browsers to refuse plain-HTTP downgrades for one year, covering every subdomain. A commented
<rewrite> rule in Web.config is ready to enable a third HTTP-to-HTTPS redirect layer at the IIS rewrite stage when the production environment is configured to receive plain-HTTP traffic.
- Both the session cookie and the authentication cookie are
SameSite=Lax to prevent cross-site delivery even on a misconfigured intermediary.
Layered Error Containment
- An unhandled application exception is contained by three independent mechanisms: (1)
Global.asax.Application_Error intercepts the exception, logs it with a correlation ID, emails the operator, and redirects to the generic error page; (2) the ASP.NET <customErrors> block routes any error that escapes the application handler to the same generic error page; (3) a loop-breaker in Application_Error short-circuits exceptions raised during Error.aspx rendering itself, preventing an infinite 302 chain on Plesk.
Independent Failure Domains
- The logging, email-notification, and CSRF-violation-reporting paths are each wrapped in their own exception handlers. A failure in any one of these subsystems is silent to the user and does not prevent the user’s original action from completing — if the audit log database is briefly unavailable, the user still saves their Checklist change; if the SMTP server is unreachable, the user still sees a clean error page.
Symmetric Failure Responses
- Failed login messages do not distinguish between “username does not exist” and “password incorrect.” The visible error message and the HTTP status code are intentionally identical — an attacker cannot enumerate which usernames exist on the platform.
- Record-access denied and record-not-found responses are identical from the user’s perspective. An attacker cannot determine whether a Category, Target, or Method ID exists at all by probing the endpoint with arbitrary numeric values.
- The 401 Unauthorized HTTP status is silently promoted to 403 Forbidden by the global error handler, so an attacker cannot distinguish “file does not exist” from “file requires authentication.”
Transparent Posture Notes
We aim to describe qFIT’s security accurately rather than aspirationally. A few items
are worth calling out so you can make informed decisions about what to entrust to the site:
Password Storage
- By current project policy, account passwords are stored as plaintext in
dbo.Logins.Password. Login comparison is performed in constant time, but the stored value itself is not hashed. Until that changes, please do not reuse a password from another site that holds anything important. The hashing primitives (PBKDF2-HMAC-SHA256 with per-user salt) remain implemented in SecurityHelper and are ready to be re-enabled by an operator decision.
Single-Operator Audit
- qFIT is operated by a single developer. There is no formal third-party security audit, penetration test, or bug-bounty programme. The protections on this page reflect the current state of the code; if you find something missing or misconfigured, please report it via Contact Us and it will be looked at directly.
Data You Provide
- qFIT stores the data you enter into your Checklist, Routines, Goals, and preferences. We do not sell or share this data with third parties. The shared
plp_Logs audit table records security and operations events (login, logout, exceptions, throttle hits, page visits) tagged with App = "qFIT"; this is used to investigate incidents and is not exposed to other users.
- The email address you provide for password recovery is used only to send recovery emails and incidental account notifications. It is not shared with any external service beyond the SMTP relay used to deliver the message.