Lifehub Penetration Test Review
Date: 2026-05-29 Reviewer: GitHub Copilot Scope: source-assisted penetration test review of the Rails application, routes, controllers, models, middleware, integrations, configuration, and dependency/security scanner output. This was a code and local tooling review, not a destructive production test or internet-facing infrastructure assessment.
Executive Summary
Lifehub has a solid Rails security baseline: Devise authentication, Pundit authorization patterns, user-owned resources, Rails CSRF defaults, encrypted credentials, SSL enforcement in production, and no active Brakeman or bundler-audit findings.
The main risks are concentrated in a few areas:
- OAuth account linking can attach a Google identity to an existing account by email without an explicit signed-in linking flow.
- Affiliate authorization redirects but does not halt execution, and records are fetched before ownership is enforced.
- MCP access tokens are designed to live in URL paths, which leaks bearer credentials into logs, browser history, referrers, and documentation.
- CSP is report-only, uses inline styles, and derives nonces from the session id.
- Production host authorization is commented out.
- Admin user editing permits the
adminflag without a controller-side guard against removing the last administrator.
Tooling Results
Brakeman
Command: bin/brakeman --no-pager
Result: passed.
- Brakeman version: 8.0.4
- Rails version detected: 8.1.3
- Active security warnings: 0
- Ignored warnings: 5
Ignored warnings shown by bin/brakeman --no-pager --show-ignored:
app/controllers/visions_controller.rb:45usespermit!for Vision slices. This is mostly mitigated byVision::Serializerallowlists and length caps, but should stay documented and tested.app/controllers/admin/users_controller.rb:170permits:admin. This is intentional admin functionality, but it still needs stronger controller-side safety checks.- Stripe Connect and Google OAuth external redirects are intentional and server-generated.
Bundler Audit
Command: bin/bundler-audit check
Result: passed. No vulnerable gems were reported.
Confirmed Findings
1. High: Google OAuth Automatically Links Existing Users By Email
Evidence:
- app/models/user/omniauth_handler.rb resolves users with
find_by_provider || find_and_link_by_email || create_new_user. - app/models/user/omniauth_handler.rb finds an existing user by email, skips confirmation if needed, and writes
provider/uiddirectly.
Impact:
If a provider response is accepted based on email alone, a Google identity can be linked to an existing Lifehub account without the existing Lifehub user explicitly initiating the link while signed in. This is especially risky for pre-existing email/password accounts, unconfirmed accounts, domain migrations, or any provider configuration issue where the email verification state is not checked.
Recommendation:
- Remove automatic email-based linking during sign-in.
- Only link OAuth providers from a signed-in account settings flow.
- If email linking must remain, require
auth.info.emailand a provider-specific verified-email flag, then send a confirmation challenge to the existing account before linking. - Add tests covering: existing email/password user, unconfirmed user, mismatched provider UID, and missing/unverified provider email.
Suggested shape:
def resolve
find_by_provider || create_new_user
end
Then add a separate authenticated action for linking a provider to current_user.
2. High: Affiliate Authorization Does Not Halt Execution
Evidence:
- app/controllers/affiliates_controller.rb fetches
Affiliate.find(params[:id])before ownership is enforced. - app/controllers/affiliates_controller.rb redirects non-owners but does not
return, raise, or otherwise halt. - app/controllers/affiliates_controller.rb, app/controllers/affiliates_controller.rb, and app/controllers/affiliates_controller.rb continue into Stripe onboarding, callback sync, and dashboard URL generation after the authorization helper is called.
Impact:
For non-owner requests, the action can continue after setting a redirect. At best this causes double-render errors and 500 responses; at worst it can trigger Stripe-side operations or status syncs for another user's affiliate before the request fails. Fetching by global id also allows existence probing by observing 404 versus redirect/error behavior.
Recommendation:
- Scope the lookup to the current user:
@affiliate = current_user.affiliate || current_user.affiliates.find(params[:id])depending on the association shape. - Replace redirect-based authorization with Pundit or a raising guard.
- If redirecting, halt immediately with
return redirect_to(...). - Add cross-user controller tests for
show,onboarding,callback, anddashboard.
Suggested minimal fix:
def set_affiliate
@affiliate = current_user.affiliate
raise ActiveRecord::RecordNotFound unless @affiliate&.id.to_s == params[:id].to_s
end
3. High: MCP Bearer Tokens Are Placed In URL Paths
Evidence:
- config/routes.rb mounts the MCP Rack app at
/mcp. - app/middleware/mcp/base_rack_app.rb extracts the token from
PATH_INFO. - config/locales/static.en.yml documents
https://app.lifehub.com/mcp/YOUR_TOKENas the setup URL.
Impact:
Tokens in URL paths are routinely captured by access logs, reverse proxies, CDN logs, browser history, screenshots, error reporting, analytics tooling, and referrer headers. MCP tokens grant direct tool access to user data based on permissions, so exposure has account-level impact.
Recommendation:
- Move token transport to
Authorization: Bearer <token>. - Keep URL-token support only as a short-lived backwards-compatible path, log a deprecation warning without the token value, and add a migration plan.
- Update MCP setup docs to show a fixed URL plus header configuration.
- Scrub
/mcp/*paths in web/proxy logs while the legacy path remains. - Consider rotating existing MCP tokens after the change ships.
Suggested extraction:
def extract_token(env)
header = env["HTTP_AUTHORIZATION"].to_s
return header.sub(/\ABearer\s+/i, "") if header.match?(/\ABearer\s+/i)
nil
end
4. High: CSP Is Not Enforced And Nonces Use The Session Id
Evidence:
- config/initializers/content_security_policy.rb allows inline styles with
'unsafe-inline'. - config/initializers/content_security_policy.rb uses
request.session.id.to_sas the nonce source. - config/initializers/content_security_policy.rb sets CSP to report-only globally.
Impact:
Report-only CSP does not block injection. Inline styles weaken the policy. Reusing a session-derived nonce is not appropriate because CSP nonces should be unpredictable and request-scoped; exposing session-derived values in markup or headers can also create unnecessary session metadata leakage.
Recommendation:
- Use a random per-request nonce, for example
SecureRandom.base64(16). - Make report-only configurable and enforce CSP in production after reviewing violations.
- Remove inline style requirements gradually, replacing them with classes or nonce/hashes where unavoidable.
- Add a security regression check that production does not boot with report-only CSP unless explicitly configured.
Suggested config direction:
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_report_only = ENV.fetch("CSP_REPORT_ONLY", "false") == "true"
5. Medium: Production Host Authorization Is Disabled
Evidence:
- config/environments/production.rb leaves
config.hostsandconfig.host_authorizationcommented out.
Impact:
Without host allowlisting, production is more exposed to DNS rebinding, host-header confusion, poisoned generated URLs, and misrouted traffic through unexpected domains. force_ssl and mailer hosts help but do not replace host authorization.
Recommendation:
- Configure explicit production hosts, including the canonical domain and known subdomains.
- Keep the
/uphealth check excluded if required by infrastructure. - Add deployment documentation for
APP_HOSTand allowed hostnames.
Example:
config.hosts = [
ENV.fetch("APP_HOST", "applifehub.com"),
/.*\.applifehub\.com/
]
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
6. Medium: Admin User Editing Can Remove The Last Administrator
Evidence:
- app/controllers/admin/users_controller.rb permits
:adminfor updates. - app/views/admin/users/_edit_form.html.erb warns when editing the last admin, but the controller still accepts the update.
- app/controllers/admin/users_controller.rb prevents deleting the last admin, but no equivalent update guard exists.
Impact:
An admin can accidentally remove admin privileges from the final administrator and lock the system out of admin management. This is an availability and operational security risk rather than a direct external privilege escalation.
Recommendation:
- Reject updates that would demote the final admin.
- Consider preventing self-demotion unless another admin exists and confirmation is explicit.
- Add tests for last-admin demotion, self-demotion, and admin promotion.
Suggested guard:
if @user.admin? && user_params[:admin] == "0" && User.where(admin: true).count == 1
redirect_to edit_admin_user_path(@user), alert: "Cannot remove the last administrator."
return
end
7. Medium: OAuth Callback Stores Broad OmniAuth Data In Session
Evidence:
- app/controllers/users/omniauth_callbacks_controller.rb stores
auth.except(:extra)in the session on failure.
Impact:
OmniAuth hashes can include credentials, tokens, scopes, provider metadata, and profile data depending on provider configuration. Storing more than needed increases the blast radius of session compromise and can put sensitive provider data into the session store.
Recommendation:
- Store only minimal retry data such as
provider,uid, andemail. - Explicitly exclude
credentials,extra, and raw provider payloads. - Add a test asserting the session never stores OAuth access tokens or refresh tokens.
Example:
session["devise.google_registration"] = {
provider: auth.provider,
uid: auth.uid,
email: auth.info.email
}
8. Medium: Google Health Error Handling Can Expose Raw API Bodies
Evidence:
- app/models/health/google/oauth_client.rb raises token exchange failures with
response.body. - app/models/health/google/oauth_client.rb raises userinfo failures with
response.body. - app/models/health/google/token_refresher.rb raises refresh failures with the raw body.
- app/models/health/google/client.rb raises unauthorized, rate-limit, server, and generic errors with raw response bodies.
Impact:
Provider error bodies can contain request details, token metadata, account identifiers, or unexpected payloads. Exceptions are logged and may also reach error tracking. This increases sensitive data exposure during integration failures.
Recommendation:
- Raise generic user-safe exception messages.
- Log structured metadata such as status code, endpoint class, and request id, but avoid full bodies unless explicitly redacted and debug-only.
- Persist sanitized
last_errorvalues onHealth::Connection.
9. Medium: MCP Rate Limiting Is Process-Local
Evidence:
- app/middleware/mcp/base_rack_app.rb initializes an in-memory hash and mutex.
- app/middleware/mcp/base_rack_app.rb enforces rate limits from that process-local hash.
Impact:
In a multi-process or multi-container deployment, each process has its own limit counter. Attackers can exceed intended limits by spreading requests across workers or containers. The limiter also resets on deploy or process restart.
Recommendation:
- Move MCP rate limiting to
Rails.cachebacked by Solid Cache or another shared store. - Consider separate limits for authenticated token id, source IP, and failed token attempts.
- Add observability around 401/429 rates.
10. Low: Logo.dev Token Is Embedded In A Query String In An Unused Fetcher
Evidence:
- app/models/integration/logo_fetcher.rb builds
https://img.logo.dev/...?...token=#{api_token}. - app/models/integration/logo_fetcher.rb stores the full URL in
logo_url.
Impact:
The class does not appear to be called by the current landing page, but if reused it would expose the Logo.dev token in logs, browser requests, image URLs, referrers, and caches.
Recommendation:
- Prefer provider-supported authorization headers if available.
- If the provider only supports query tokens for browser image URLs, proxy/cache logos server-side and never expose the secret token to the browser.
- Remove the unused fetcher if the static logo list is the intended implementation.
11. Low: Google Health Webhook Allows Unsigned Requests In Development
Evidence:
- app/controllers/webhooks/google_health_controller.rb returns
truewhen the webhook secret is blank in development.
Impact:
This is development-only, but it can hide missing-secret bugs and makes local webhook endpoints accept arbitrary requests. If environment checks or credentials are misconfigured during deployment, teams may miss verification failures until late.
Recommendation:
- Require a development webhook secret too, generated from credentials or a local env var.
- Keep explicit test helpers for unsigned webhook cases rather than bypassing the controller guard.
- Add a production boot check that fails if Google webhooks are enabled without a secret.
12. Low: Google Health Webhook Sync Job Has No Job-Level Timeout
Evidence:
- app/jobs/health/webhook_sync_job.rb calls the sync runner directly.
Impact:
The underlying HTTP clients have open/read timeouts, which helps. A job-level timeout would still protect Solid Queue workers from long multi-call syncs, retries, or future code paths that block longer than intended.
Recommendation:
- Add a bounded timeout around webhook sync work.
- Make retry behavior explicit for timeout versus provider throttling.
- Add queue metrics for job duration and failures.
Areas Reviewed With No Immediate Issue Found
- Dependency advisories:
bin/bundler-audit checkfound no vulnerable gems. - Static Rails scan: Brakeman found no active warnings with the current ignore file.
- User-owned domain resources generally follow
current_user.resources.find(...)or equivalent scoping. - Admin-only mounted engines are wrapped in an admin Devise route constraint.
- Public documentation markdown is sanitized before rendering.
- Vision writes use
permit!, but the serializer enforces section/key allowlists and field length limits. - OAuth state for Google Health uses
MessageVerifier, a nonce, and TTL validation. - Stripe and Google external redirects reviewed are server-generated rather than direct user input.
- MCP tokens are stored as SHA256 digests with revocation support.
- Production uses
force_ssl,assume_ssl, secure cookies through Rails defaults, encrypted credentials, and parameter filtering for token-like fields.
Recommended Remediation Order
- Fix affiliate authorization control flow and add cross-user tests.
- Remove Google OAuth email auto-linking or move it to an explicit signed-in linking flow.
- Move MCP tokens out of URL paths and rotate existing tokens after migration.
- Enforce CSP in production with random request-scoped nonces.
- Enable production host authorization.
- Guard admin user updates against removing the final administrator.
- Minimize OmniAuth session storage and sanitize Google Health error handling.
- Move MCP rate limiting into a shared cache store.
- Clean up lower-risk webhook, Logo.dev, and webhook job timeout hardening items.
Suggested Follow-Up Tests
- Affiliate controller cross-user tests for all member routes.
- OmniAuth tests for existing email/password accounts and unverified email metadata.
- MCP request tests for
Authorization: Bearertoken transport once implemented. - Production configuration test asserting CSP enforcement and host allowlisting.
- Admin users controller tests for last-admin demotion prevention.
- Google Health tests asserting exceptions do not contain raw response bodies.