Site-Wide Security
Data Protection
- Database queries use parameterized statements (bound parameters through a single centralized data-access layer), 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 a shared safe-integer parser — 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 from data-management screens 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: 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 by the application's global request handler — 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, HTTP handlers) benefits from the same recovery.
- Sessions use non-default cookie names (one for the session ID, one for the authentication 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 logged 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) re-check authentication on the server as a defense-in-depth measure — 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 stored procedure requires a user ID 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 hosting platform's 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 lets a form submit only to this site and its four sister sites (the login step that recognizes a device you already verified on one of them passes through there), so no form can be repointed to send user data to an outside 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 configuration (for production IIS) and a request-start handler in the application (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. The encrypted, MAC-protected ViewState cannot be read by an attacker and cannot be modified without invalidating the signature.
- The keys that sign and encrypt ViewState (and the Forms Authentication ticket) are explicitly pinned in configuration (HMAC-SHA256 for signing and AES for encryption) rather than being auto-generated per worker. Pinned keys mean a signed or encrypted payload stays valid across application-pool recycles and is verified identically by every worker process, with no key-rotation window for an attacker to exploit; the keys are unique to qFIT, so a ticket minted here is never honored by a sister application.
- 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 through a per-user key, which is set during the initialization 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 rejects any form submission containing potentially dangerous HTML or script characters before it reaches application code.
- ASP.NET response-header checking is enabled. The framework encodes carriage-return and line-feed characters in any value written to a response header, neutralizing HTTP response-splitting and header-injection attempts where attacker-controlled data (a redirect target, a cookie value) would otherwise inject a second header or a forged response body.
Cookie & Token Handling
- Authentication tokens and session IDs are stored in cookies only — 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 user ID 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, preventing authentication tickets from being accepted by other applications on the same server or domain.
- Both the session cookie and the authentication cookie 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 cookie default 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, 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 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 creation (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 (login, password reset, invite, contact form, and join) in the site's configuration — 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 hosting platform's 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 steers search-engine crawlers away from account-related pages, from the framework-internal directories (application code, application data, startup configuration, compiled binaries, build output, temporary files, and installed packages), and from the user-upload folder root — so a crawler cannot enumerate uploaded attachments even though an individual file URL remains reachable for legitimate use.
Error Handling
- End users see a friendly error page with no internal stack content; raw technical detail (stack traces, SQL text, file paths) is never exposed to ordinary users.
- Every unhandled exception is logged to the central audit log with full request context — the request URL, HTTP method, client IP, session ID, and the complete exception chain (up to five levels of inner exceptions) — so failures can be investigated from the log without surfacing any detail to the user.
- Every exception is also appended to a size-capped, self-rotating diagnostic file (~5 MB) that captures crashes occurring after the response has begun flushing — the point at which the normal database-log-plus-error-page path can no longer be shown. Rotation is a single atomic move to a backup sibling file (replacing any previous backup), so the total on-disk error data stays bounded and there is no partial-truncation window. The file lives in a protected application-data folder, which IIS never serves over HTTP.
- Every 500-level error generates a short 8-character correlation ID that is shown to the user on the error page and logged into the central audit log's correlation-ID field. 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 the global error handler detects exceptions that occur while rendering the error page itself and short-circuits the redirect, preventing an infinite 302 chain on a misconfigured production environment.
- A postback whose ViewState fails validation — typically a form submitted across a deployment or after the session was renewed — is recovered gracefully rather than surfaced as a server error: the request is logged, the stale submission is discarded, and the page reloads with a brief “form refreshed” notice. A signed-but-unreplayable ViewState never produces an error page.
Security Logging
- Security-relevant events are logged to the shared central audit log (tagged with the application name 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 event-category tags (login, failed login, logout, password change, password reset, security violation, access denied, exception, preferences saved, account created) 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.
- User identifiers (usernames, emails, phones) are masked before being written to the log — an email becomes
j***@gmail.com, a phone becomes ***6789. Enough signal remains to correlate repeated attempts or attack patterns, but the raw value is never persisted into the log in clear text.
- The audit-log insert is queued onto a background work item, so the user-visible request is never held up by the write; the request-context-dependent fields are captured synchronously on the request thread before the work is queued.
Maintenance Mode Gate
- The site can be taken offline for maintenance by flipping a single configuration flag. When enabled, a global handler in the application redirects every request to a dedicated maintenance 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 (style sheets, scripts, images, shared assets, favicon, manifest, service worker, offline page) are bypassed so the maintenance page itself renders with its CSS and icons.
Logout & Session Termination
- Logging out is a multi-step process: the Forms Authentication logout call revokes the authentication cookie, the Remember-Me token row in our login-token store is deleted, all session data is cleared, and the server-side session is abandoned, which marks it for immediate destruction.
- The authentication cookie and the session cookie 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 the logout or session-abandon calls alone.
- The logout event is logged — including user ID 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 a 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 a shared back-link resolver, 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 mail client's SSL setting in the site's configuration mandates encryption — plaintext delivery is not possible.
- SMTP credentials are read from the site's configuration at runtime via the framework's mail-settings section. The SMTP client 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.
- Every email subject is passed through a header-sanitization step that strips carriage-return and line-feed characters before the message is built, so a user-supplied value flowing into a subject line cannot inject additional SMTP headers.
- Notification-class emails are queued on a background work item so the user's request returns immediately; an SMTP error on that background thread is swallowed and never surfaces to the user or blocks the action that triggered it. The swallowed failure is still logged as a warning with the recipient redacted to its domain only (
***@domain), so delivery problems can be diagnosed without persisting a recoverable third-party address in the log.
- 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, Exercise/Target/Category names, notes fields on detail pages, error messages echoed back from validation — passes through the framework's HTML encoder 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 HTML-encode every user-controllable substring before concatenation.
Built-in Provider Isolation
- The ASP.NET role manager is disabled entirely. Role decisions are made exclusively by application code reading from session data and the account's stored role — the framework's built-in role infrastructure plays no part.
CSRF Protection for AJAX, Page Methods, 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 script assignment injected on every page render; 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 reads the token on every call and forwards it as a custom
X-QFIT-CSRF request header. A request hook in the framework's AJAX library also injects the same header on every classic page-method call, so the entire AJAX surface area is covered without per-page changes.
- Every state-changing page method (such as the Checklist and To-Do check-offs and the My Messages record loader) validates the CSRF token 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 security-violation event with the requesting user ID and the request path.
- When a token goes stale (after a deploy or an app-pool recycle with the tab still open), the server tags the rejected response X-QFIT-CSRF-Status: expired and the client transparently fetches a fresh token from a dedicated refresh handler and replays the call once, so a benign stale token never surfaces an error. That refresh handler returns HTTP 401 for an unauthenticated or expired session and HTTP 429 once a single user exceeds twenty refreshes per minute (recording the attempt), so the recovery path is authentication-gated, same-origin-only, and hard-throttled against any attempt to enumerate or brute-force tokens.
Configuration Whitelisting
- User-selectable preference values are validated server-side against an explicit allowlist before persistence: the color scheme must be one of two allowed values (light or dark); the site mode must be 1, 2, or 3; the font size 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 constrained to a fixed dropdown (bug, quality, enhancement, feature, security, access, support, abuse, other); server-side EventValidation rejects any value not present in the rendered control before the database insert.
- Comma-separated user ID lists in the site's configuration parse each token as an integer. Non-integer tokens, negative values, and zero are silently dropped so a typo cannot accidentally widen the set.
Remote Logout (Log Out Everywhere)
- Each logged-in device carries a per-device security-stamp cookie (
HttpOnly). On every session rebuild the device's stamp is compared against the account's current stored stamp; if you rotated the stamp from another device, this device's still-valid ticket stops matching and is logged out on its next request. This is what lets a single “log out everywhere” action terminate every other logged-in session even though the authentication ticket itself is long-lived.
- The check is fail-open by construction — it only ever rejects when both a device cookie and a stored stamp are present and differ — so a missing or not-yet-issued stamp can never force an unexpected logout.
Reporting a Security Problem
- If you believe you have found a security vulnerability in qFIT, please report it privately rather than posting it publicly. Email
ADMIN@qFITpro.com, or use Contact Us with the “Security Issue” category — that link pre-selects the category for you, and reports filed under it are handled separately from ordinary feedback.
- Please include enough detail to reproduce the issue: the page or URL, what you did, what happened, and what you expected. A short proof-of-concept is welcome. If a report needs a screenshot, the Contact Us form accepts image attachments.
- You will get a human acknowledgement. qFIT is a small independent site with no bug-bounty program and no monetary rewards — there is no payment, and none is implied by this invitation to report.
- Please give us a reasonable chance to fix an issue before disclosing it publicly, and please do not access, modify, or delete data belonging to any account other than your own while investigating. Testing that degrades the service for other people — load or denial-of-service testing, spam, or brute-forcing live accounts — is not authorized. Within those limits, a good-faith report is welcome and we will not pursue action over it.
- The same contact details are published in machine-readable form at /.well-known/security.txt, the conventional location automated tooling checks.
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 the site's configuration — 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.
Account Lockout
- In addition to the in-memory IP throttle, qFIT tracks failed login attempts in a persistent failed-attempt counter and lock-until time on the account record. Once the failure count crosses the configured threshold the account is locked for the throttle window, and a locked account is refused even when the correct password is later supplied, until the lock expires. Because the counter lives in the database, the lockout survives an application-pool recycle — an attacker cannot reset it by waiting out a restart.
- After an incorrect password on an existing account, the page shows how many attempts remain before lockout, while a non-existent username still receives the identical generic failure message — so the countdown never reveals whether a username exists.
- The failed-attempt counter is incremented with a single atomic database statement (an update that returns the new count), so two simultaneous failed attempts cannot each read a stale count and slip past the lockout threshold — the database serializes the increment. The same statement stamps a last-failed-attempt timestamp on the account record, giving a forensic timeline directly on the account independent of the central log.
Credential-Stuffing Detection
- qFIT counts failed logins originating from a single IP within the throttle window; when an IP crosses the configurable credential-stuffing threshold, a security event is logged and a one-time alert is sent to the operator. The alert is de-duplicated with a per-IP flag so a sustained attack generates one notification per window rather than a flood, and the whole routine is wrapped so a mail-server outage can never block a legitimate login. This credential-stuffing alert is the only email a login event ever generates.
Credential Protection
- Failed logins are rate-limited and, for an existing account, temporarily locked out after repeated failures. The error message does not state which field was wrong.
- 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 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 clearing either flag 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 and its cookie is overwritten with an empty value bearing a past expiration date. The browser receives a fresh session 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 user ID only and is set with full protection (encrypted + HMAC-signed). The bearer cookie cannot be inspected or modified by the client.
Remember-Me Token
- The optional “remember me” cookie carries a random 64-character token. The token is hashed with SHA-256 before storage in our login-token store — 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. Its expiry is extended (slid forward) on each silent use, a fresh token is issued on each interactive login, and the underlying row is deleted on explicit logout — so a stolen device's persistent login is revoked the next time the legitimate user logs out.
- Token rows carry an explicit expiry timestamp and are rejected past expiry, and the silent auto-login path re-checks the joined account is still active and approved before establishing a session — an expired token or a revoked account matches nothing. An abandoned device's Remember-Me token cannot be replayed indefinitely.
- When a password is reset, every Remember-Me row for that account is deleted, forcing all silent-login devices to re-authenticate with the new password.
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 is a standard password input, 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 signaling 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.
Account Recovery & Joining
Account Recovery
- The account-recovery flows (forgot username, forgot password) accept an email address or phone number and respond with the identical “if an account exists, you'll receive…” message on every exit path — no match, no contact on file, or profile missing — so the form cannot be used to probe which addresses are registered.
- Both flows are throttled simultaneously by IP and by the submitted identifier; a throttled request is logged as a security violation with the identifier masked.
- If the submitted email or phone matches more than one account, the request is refused rather than acting on an arbitrary match (which could otherwise leak the lowest-ID account's credential to whoever owns a shared contact), and the same vague confirmation is still returned.
- Forgot Password never sends a password. It emails a single-use link containing a 256-bit token from a cryptographic random number generator; only the token's SHA-256 hash is stored, so a database reader cannot reconstruct the link. It expires in 45 minutes and is invalidated once used.
- Because the reset is a link rather than a new credential, nothing about the account changes until the recipient opens it and chooses a password — so an unauthenticated request can no longer invalidate anyone's password, and an unwanted reset email can simply be ignored.
- Choosing the new password clears the failed-attempt lockout, and revokes every “remember me” token, so a browser that was logging in automatically is logged out.
Joining
- Final account creation is throttled by IP; a throttled attempt is logged as a security violation and rejected.
- A server-side password policy is enforced independently of any client-side check: a minimum length, at least three of the four character classes (upper / lower / digit / symbol), a minimum number of distinct characters, no leading or trailing space, no common weak substrings, and the password may not contain or equal the username.
- Usernames are validated server-side to a length range, an allow-listed character set, a minimum distinct-character count, and a ban on common weak substrings.
- Every prior-step value (name, phone, email, city, postal code) is re-validated server-side for length, allowed-character pattern, and profanity before the account row is written, so a bot posting past the HTML field limits cannot push an over-length or malformed value into the database.
- Joining refuses to create a second account when the submitted email or phone is already on file, directing the user to log in instead. The account, profile, and contact rows are all written through parameterized statements with width-truncated values and the server-issued user ID — never a client-supplied identity — and a duplicate-username race is caught and surfaced as a clean “already in use” message rather than a raw database error.
- On successful account creation, any pre-existing “remember me” token presented by the browser is deleted from our login-token store and every request cookie is expired, so a stale persistent login left behind on a shared browser cannot carry over onto the brand-new account.
Per-Record Ownership (Checklist, Categories, Targets, Exercises)
Stored-Procedure Ownership Gate
- Every save and delete on a Category, Target, or Exercise routes through a stored procedure that requires a user ID 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 a shared safe-integer parser and the current user ID is taken from the rebuilt session rather than from any user-supplied source.
Detail Pages
- The Category Details, Target Details, and Exercise Details reads validate their query-string IDs and bind them as parameters — a non-numeric or negative ID never reaches the database.
- The “Save Notes” handlers on each Details page (historically tagged with a SQL-injection TODO) were migrated to parameterized statements through the centralized data-access layer, with bound parameters.
Checklist State Changes
- The Checklist and To-Do check-off page methods validate the X-QFIT-CSRF header before doing any work, then verify the calling user owns the Exercise whose state is being toggled. A check-off on someone else's Exercise is rejected at the data layer.
Write Rate-Limiting & Input Validation
- Both check-off page methods apply a per-user write throttle under a shared key, defaulting to 300 writes per minute (configurable through the throttle's maximum-attempts and time-window settings) — generous for a fast check-off session but capping a malicious script at roughly five writes per second, even one alternating between the Checklist and To-Do pages. A throttled call is logged as a security violation.
- Each call rejects any record type outside the expected set (Category, Target, or Exercise) and any non-positive ID before touching the database, and every INSERT/DELETE is filtered on the caller's own user ID, so a check-off only ever affects a row the caller owns.
- Every page-method on these pages — not only the state-changing check-off, but also the read methods that load data — validates the X-QFIT-CSRF token as its first action and returns an empty result on failure.
Record-Level Access Guards
- The Category, Target, and Exercise detail reads filter every load on “owned by you, or the shared public template”. A record owned by another user renders a “record does not exist” message with the action controls hidden — the same response whether the ID is unowned or genuinely absent, so record IDs cannot be probed.
- The edit pages apply the same owner-or-public-template filter when binding the form, not just on save, so another user's private record's name and settings cannot even be viewed by tampering with the ID. The save and delete handlers re-validate the ID and re-check authentication before doing any work, so a session that expired mid-edit is caught without losing input.
Account & Session Security
Session Rebuild After Interruption
- ASP.NET in-process sessions are lost if the server's application pool restarts. When this happens, the application's global request handler 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 your account and profile records. 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, HTTP 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 logged out at the rebuild step.
- Session rebuilds are logged at INFO level with the user ID 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
- Display preferences (theme, font size, site mode) are not cookie-based — they are stored on your account and loaded when you log in, which is why they follow you to any device. There is no preference cookie carrying identity, an authentication token, or a permission flag.
- The browser does set two small state cookies client-side: one for your time zone (so reminders and Quiet Hours follow you when you travel) and one for screen size (diagnostic only). Both are written with
SameSite=Lax and, over HTTPS, the Secure flag, and carry no personal data beyond those two values.
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 page method 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 the request's authenticated principal. The framework populates this principal regardless of session state, so AJAX operations remain authenticated through transient infrastructure events without forcing the user to log in again.
- The fallback only succeeds when the Forms Authentication 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 follows a strict priority order: (1) read the user ID from the session if present and it 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 (the Forms Authentication fallback) succeeds, the resolved user ID is restored into the session 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.
- When the ticket path is taken, the resolver re-checks the account's active and approved flags against the database and logs the user out of Forms Authentication when the account no longer exists or has been revoked — so a banned or deleted user cannot keep riding a still-valid long-lived ticket through AJAX and handler endpoints, independent of the rebuild-time check. If the database is briefly unreachable the check fails open for that single request only and is re-evaluated on the next one, so a transient outage neither locks everyone out nor grants a lasting bypass.
Anonymous Access Boundaries
- Anonymous users can view a curated set of public pages (About, Help, Tips, Full Manual, Quick Tour, Contact Us, etc.). Every other page is gated by the master page's public-pages allowlist and a per-page authentication check, plus an explicit anonymous-user deny rule in the site's configuration for the most sensitive ones (such as Preferences and My Messages).
- The invite flow (the Invite page) is open to anonymous users so word-of-mouth invites work without forcing the inviter to join first, but the per-IP rate limit applies regardless of authentication status, and a per-user limit stacks on top for authenticated users.
Preferences Persistence
- Preferences (theme, font size, site mode, time zone) save to both the 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.
Re-Authentication for Credential Changes
- Changing your username or password requires re-entering your current password, even though you are already logged in. This step-up check stops someone at a walked-away, still-logged-in browser from silently taking over the account by changing its credentials. An incorrect current password is rejected, recorded as a security violation, and counted against a dedicated per-user throttle (10 attempts in 15 minutes), so the current-password prompt cannot be brute-forced across repeated postbacks.
- The same current-password re-authentication gate also guards self-service account deactivation, so a hijacked open session cannot deactivate the account either.
- Changing the login email address on My Profile goes through the same gate and the same throttle. Password resets and login codes are sent to that address, so without the check an open session could redirect them and then reset the password. The rest of the profile saves without a password; the prompt appears only when the email address itself is being changed, and a refused change saves nothing.
Self-Service Account Deactivation
- Deactivating your account is a reversible soft delete: it turns the account's active flag off on your own record only (after the current-password re-authentication and an explicit confirmation check) and preserves your data so it can be reactivated — nothing is hard-deleted. The update is parameterized and scoped to your own user ID.
- Deactivation also deletes every “remember me” token for the account and logs out the current device. Because the active flag is re-checked on every login, every session rebuild, and every AJAX identity resolution, a deactivated account is locked out of all devices immediately — a still-valid long-lived ticket on another device stops working on its very next request.
Log Out Everywhere
- The “log out everywhere” control rotates your account's server-side security stamp (which makes every other device's stamp cookie stop matching on its next request — see Remote Logout in Site-Wide Security above) and deletes every “remember me” token so no other device can silently auto-login, then logs out the current device too. It is deliberately not gated behind a password re-prompt: a session-ending action cannot read your data or change your credentials, so by the least-privilege principle it stays available even if you cannot recall your password on the spot.
Contact Form Security
Form Integrity
- The contact form is protected by the framework's ViewState MAC + EventValidation + per-session ViewState user-key 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 rejects any submission containing potentially dangerous HTML or script characters at the framework boundary, before the submit handler 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 user ID (or a shared anonymous 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 the site's configuration, separately from the login and password-reset throttles.
- Every throttled rejection is logged as a security-violation event with the offending IP address.
Bot Detection
- The contact form includes a visually-hidden,
aria-hidden, tabindex="-1", autocomplete="off" honeypot field. A submission that fills it is silently dropped, logged as a security violation with only a masked, length-capped preview (never the raw payload), and shown a fabricated “thanks” message so the bot believes it succeeded and does not retry.
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 feedback ID 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 offers a fixed set of values (bug, quality, enhancement, feature, security, access, support, abuse, other); the submission requires a category to be chosen, and server-side EventValidation rejects any value not present in the originally rendered dropdown before it reaches the database.
- The comment body is capped at 4000 characters by an explicit server-side length check — a multiline textbox's maximum-length attribute is not enforced by the browser, which is exactly why the server-side check exists — 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 Messages Visibility Gate
- The My Messages page that lets you read your own submissions filters every query on the current user, so a user can never load another user's row even by manipulating the feedback ID in the request. The detail-loading page method validates the X-QFIT-CSRF token first, resolves the user from session-or-ticket, then filters the row on the feedback ID, the user ID, and the application — so you cannot load another user's row, nor even your own submissions filed under a different application, by tampering with the feedback ID. 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, How I Use qFIT, 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 the Checklist and To-Do check-offs, the My Messages record loader, 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. 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.
- If even the cached offline page is unavailable, the worker still resolves the request with a fully self-contained HTTP 503 page whose markup loads no external CSS, script, or fonts — so the offline state always renders rather than a broken, network-dependent shell.
- On update, the new worker deletes prior-version caches and takes control immediately, so a security-relevant worker change cannot sit deferred behind an open tab; navigation preload is enabled so the first navigation is not stalled while the worker boots.
- 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 AJAX 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 re-check authentication 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 the connection state before attempting to send, and that state is not a raw read of the browser's flaky online flag: an offline event is debounced for three seconds and then confirmed with a
HEAD request to /favicon.ico (a tiny, no-store probe) before the connection is treated as down. If the browser really is offline the call is short-circuited locally and the supplied error handler receives an offline message immediately, without firing a doomed request — but a momentary cell-tower flicker no longer produces a spurious “offline” failure.
- An online/offline event listener toggles a CSS class on the page 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 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 a confirmation attribute 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 canceled 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 a page-level variable (injected 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 page-method call path is also covered: a request hook in the framework's AJAX library injects the same
X-QFIT-CSRF header on every page-method 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 local storage or session storage. Browser storage holds only per-page UI preference state (filter toggles, panel open/closed — each validated against a known set before being applied), scroll positions, and your unsent Contact Us draft.
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.
Duplicate-Request Coalescing
- The shared AJAX helper keys every call by its URL plus its serialized request body and coalesces duplicates: a second identical call made while the first is still in flight does not open a second network request — it registers its callbacks against the pending one and is notified when that settles. A rapid double-click, or two components triggering the same save at once, therefore cannot double-fire a state-changing page method at the transport layer.
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 are all suppressed. Automated scanners cannot fingerprint the platform from response headers.
- The
Server banner is additionally stripped at the native IIS request-filtering layer, not only in managed code, so the server-identity header is absent even on responses produced before the managed pipeline runs. The same request-filtering block adds the scratch temporary directory to IIS's hidden-segments list, so it returns 404 and cannot be requested over HTTP at all.
Request Size Limits
- Maximum request size is capped at 100 MB at both the ASP.NET and IIS layers — reduced from a legacy 1 GB limit that allowed a single oversized upload to tie up a worker thread and pre-allocate memory — bounding the resource cost of any single request.
Layered Error Routing
- HTTP status codes 401, 403, and 404 are routed to the application's generic error page via the ASP.NET custom-errors configuration. The framework's global error handler in the application intercepts every unhandled application exception, logs the full context with an 8-character correlation ID, and routes the user to the same generic page.
- The 500 entry is intentionally absent from the custom-errors configuration so a 500 inside the error page itself doesn't bounce back through the same redirect rule. The loop-breaker in the global error handler catches the recursive case explicitly and short-circuits.
Static Folder Authorization
- Asset folders (style sheets, scripts, and images) and PWA plumbing (the web app manifest, the service worker, and the offline page) are explicitly opened to anonymous access through scoped location rules in the site's configuration. Authentication-restricted pages inherit the default deny posture by virtue of not appearing on the allowlist, and the most sensitive ones (such as Preferences and My Messages) carry an explicit anonymous-user deny rule as defense in depth.
- Framework internals (application code, application data, compiled binaries, and build output folders) 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 an empty value and silently bypasses an access check).
- The compiler targets .NET Framework 4.8. The selected runtime supports the cryptographic primitives (HMAC, AES, SHA-256, PBKDF2) the application relies on for ticket protection, ViewState, and Remember-Me token hashing.
Database-Layer Defenses
Parameterized Query Discipline
- Every new database call in the application flows through a single centralized access layer whose every public method requires a parameterized SQL string and an array of bound parameters. 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 parameterized queries through that access layer during the May 2026 security pass — high-risk import paths that previously concatenated raw form text into SQL are now fully parameterized.
- SQL strings in application code use named placeholders that are bound as parameters. 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 (for integers, strings, booleans, and nullable dates) check for database nulls 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 Exercises require a user ID 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.
- Every database command carries an explicit 30-second timeout (configurable without a code change), so a slow or stuck query is abandoned rather than pinning a request thread indefinitely.
- The connection string is read once at class-load time from a single configuration 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 authentication safety net; (4) the stored procedure itself filters on the user ID. 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 a maximum-length limit on the textbox plus a server-side length check; (3) the database column refuses values that exceed its declared length; (4) the rendering site HTML-encodes the value 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 hosting platform's 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-out rewrite rule in the site's configuration 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) the global error handler intercepts the exception, logs it with a correlation ID, and redirects to the generic error page; (2) the ASP.NET custom-errors configuration routes any error that escapes the application handler to the same generic error page; (3) a loop-breaker in the global error handler short-circuits exceptions raised while the error page itself is rendering, preventing an infinite 302 chain on the hosting platform.
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, a credential-stuffing alert or recovery email fails silently while the user's action still completes.
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 Exercise 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.”
Coordinated Disclosure
- If you believe you've found a security issue, please report it via Contact Us and it will be looked at directly.
How We Secure qFIT
🔐 Reporting a Security Issue
Found a vulnerability? Report it through
Contact Us and
leave the category set to Security Issue — that is what labels the report as
security in our inbox. Describe it in the message; please do not post it publicly.
It is a long page with no jump chips, so the floating ☰ button is the way around
it — it lists every section and jumps straight to one. Nothing here is folded away,
so Ctrl+F and printing both reach every word.
This page describes the protections as built. It is not a promise of future
behavior.