Security Architecture
This document summarizes the current security posture of the MTRO PRO platform and the active audit backlog. It is written for engineering, product, and operations stakeholders who need a single reference for how the platform protects tenant data, guest data, payments, leases, and messaging.
Last updated: 2026-05-19.
Scope
The platform is composed of these security-relevant services:
core: the backend API and the main authorization boundary.admin-panel: the property-manager dashboard.guest-app: the guest-facing application.superadmin-panel: internal operations panel.landing: public property and entity discovery pages.pdf-converter: local document conversion service for PDF, HTML, and Word workflows.ff-scraper: Furnished Finder import microservice.url-shortener: public payment-link redirect service.automation: internal scripts, billing tracker, and migration tooling.
The backend API is the source of truth for access decisions. Frontend hiding or disabling is treated as UX only, never as the enforcement layer.
Threat Model
Primary assets:
- User and guest accounts, including session and refresh tokens.
- Entity, property, booking, lease, task, payment, vendor, lead, and conversation data.
- Stripe Connect account references and payment records.
- Uploaded files and generated lease PDFs in DigitalOcean Spaces.
- Email, SMS, WhatsApp, and in-app notification history.
- Internal admin and superadmin capabilities.
Primary attacker classes:
- Unauthenticated internet users hitting public endpoints.
- Authenticated guests trying to access owner or another guest's data.
- Team members with restricted property access trying to access another property.
- Entity users trying to cross tenant boundaries.
- Compromised webhook callers or spoofed provider callbacks.
- Abusive users attempting account takeover, brute force, SSRF, or file-upload attacks.
Authentication
Users and Guests
core supports two authenticated user classes:
- Dashboard users from
database/schemas/users.ts. - Guest users from
database/schemas/guests.ts.
Login endpoints are rate-limited by IP and email through authLimiter
(5 / 15 min). Password recovery endpoints are rate-limited by
recoveryLimiter (3 / hour).
Passwords are stored with bcrypt using cost 12. The legacy SHA-256 password format is accepted only during login/change flows and is opportunistically re-hashed to bcrypt after a successful verification.
Sessions
New session tokens use AES-256-GCM authenticated encryption in libs/jwt.ts.
The token format is:
v2.<iv-base64url>.<tag-base64url>.<ciphertext-base64url>
validateSession() in libs/crypto.ts accepts only the v2 format. Legacy
AES-CBC session tokens are rejected after the MTR-997 cleanup. Refresh tokens
are stored server-side on the user/guest record. Access tokens are rejected if
expired, and expired refresh tokens are removed when encountered.
API Keys
API keys use the mtro_ prefix and are validated in validateSession().
Current API keys store:
keyPrefixfor indexed lookup.keyHashfor bcrypt comparison.entityIdfor active-entity scoping.active,expiresAt, andlastUsedmetadata.
Legacy plaintext API-key rows are accepted during rollover and are upgraded to the hashed format when first used.
Authorization
Authorization is enforced server-side. The central permission grid lives in
core/libs/access-control.ts and exposes capabilities to the frontend so the
UI can hide unavailable actions.
Current entity roles:
property_managervirtual_assistantmaintenance_staffproperty_owner
Superadmins (user.role === "admin") bypass entity-role checks. Guests are
denied access to owner-side resources unless a route explicitly implements a
guest flow.
Property-level isolation uses:
propertyAccess: "all" | "selected"allowedPropertyIds: string[]- helper functions in
core/libs/property-access.ts
Routes that operate on property-scoped resources should use
requireResourceAccess(user, action, res, { entityId, propertyId }) or an
equivalent explicit check. The frontend must not be considered authoritative.
Tenant Isolation
Tenant isolation is generally enforced through the authenticated user's active entity and, when applicable, property access:
validateSession()projects the activeMembershiponto legacy fields (entity,entityRole,propertyAccess,allowedPropertyIds) so old route code reads the correct active-entity context.- Multi-entity sessions carry
activeEntityId. - API keys are scoped to the entity active when they were created.
- Property-scoped notification fan-outs call property-access helpers so users only receive booking/payment/conversation notifications for properties they can access.
Public Endpoints
Public endpoints exist for explicit product flows only:
- Health/status endpoints.
- Login, refresh, recovery, registration, invitation, and verification flows.
- Public property/entity/badge/availability pages for the landing site.
- Token-based lease signing.
- Public Stripe checkout redirect wrapper.
- Provider webhooks after signature verification.
- URL-shortener redirects.
pdf-converterfile retrieval by UUID for converted JPG outputs.
Public endpoints should either be read-only, token-bound, provider-signed, rate-limited, or explicitly documented as public.
API Hardening
core/app.ts applies platform-wide middleware:
- CORS allowlist from
CORS_ORIGINSor the configured frontend URLs. - Localhost allowance for development.
- JSON and URL-encoded body parsing with
50mblimit. express-mongo-sanitizefor NoSQL operator stripping.- production error redaction through
redactErrorReasonsMiddleware. - request timeout extension for upload-heavy and document-rendering endpoints.
- reverse-proxy trust for DigitalOcean App Platform rate-limit correctness.
Rate Limiting
Central rate-limit policies live in core/libs/rate-limit.ts:
authLimiter: login-sensitive endpoints.recoveryLimiter: password recovery.checkoutLimiter: public Stripe checkout wrapper.signTokenLimiter: token-based lease signing.publicLimiter: expensive public landing endpoints.
The test environment disables these limiters to avoid flakes.
MTR-994 audit note: verification-code send and verify endpoints now have route-level limiters, and per-account failed-attempt counters lock the active email or mobile code after repeated misses until a new code is issued.
File Uploads and Object Storage
core/routes/upload.ts uses memory-backed multer with:
- 10 MB per file.
- image MIME pre-filter.
- magic-byte validation for PNG, JPEG, GIF, and WEBP.
- explicit SVG rejection.
- entity-scoped Spaces keys:
uploads/{entityId}/{uuid.ext}.
Uploads default to public unless isPublic=false is supplied. Private objects
are returned as private://bucket/key; access requires /upload/retrieve,
which validates that the authenticated user belongs to the entity encoded in
the object key and returns a 1-hour signed URL.
Webhooks
Resend
/webhooks/resend verifies Svix headers using the raw request body captured in
app.ts. This route must remain behind the raw-body capture logic; otherwise
signature verification becomes unreliable.
Twilio
Twilio inbound SMS and status callbacks call verifyTwilioRequest() from
core/libs/twilio-signature.ts, which validates X-Twilio-Signature against
the public webhook URL. Invalid or missing signatures return 403.
Stripe
Payments are created through Stripe Checkout. Card and ACH details are handled by Stripe; MTRO PRO stores payment metadata, Checkout session IDs, routing references, and Stripe Connect account IDs, not raw payment credentials.
Payments
Payment security controls:
- Stripe Checkout is used for card and ACH collection.
- The public payment URL points at
/payments/checkout/:id, which can refresh expired Checkout sessions instead of exposing raw Stripe session URLs as the long-lived source of truth. - Payment routing resolves through a central Stripe account resolver.
- Stripe Connect account management is owner/superadmin gated.
- The platform fee is applied through Stripe
application_fee_amount. - Payment lists are entity-scoped and property-filtered for selected-property users.
Documents and Lease Rendering
Lease rendering uses the local pdf-converter service, not an external
document conversion SaaS.
Important controls:
corecalls the converter withPDF_CONVERTER_API_KEYwhen configured.- Lease merge fields are HTML-escaped before substitution.
- Word imports are proxied through
core/routes/templates.tswith a 25 MB multer limit.
Open audit items:
pdf-convertercurrently allows conversion endpoints without auth ifAPI_KEYis unset. Production must always setAPI_KEY; the service should be hardened to fail closed unless an explicit local-dev override is present.- Direct
pdf-converterupload endpoints need explicit file-size limits. - WeasyPrint may fetch external URLs referenced by user-controlled HTML; URL fetch restrictions should be reviewed before exposing the converter outside a private network.
Messaging and Notifications
Conversation messaging supports in-app, email, SMS, and WhatsApp channels.
Security-relevant controls:
- Conversation access is entity-scoped and property-scoped for booking conversations.
- Lead and vendor conversations retain their entity-level routing.
- Reply-by-email uses conversation-specific aliases.
- Inbound SMS routing uses Twilio signature verification and ambiguity rejection when a phone number maps to multiple possible conversations.
- Delivery status tracking records provider failures instead of silently assuming success.
MTR-990 audit note: raw conversation-message email bodies, webhook-generated conversation notifications, task notes, manual payment reminder text, and campaign email text now pass through a shared HTML-escaping helper before newline conversion.
Frontend Security
Frontend applications should assume all authorization decisions can be bypassed client-side. Current controls:
- Access capabilities are exposed by the backend so the UI can hide unavailable features.
- Protected routes redirect unauthenticated users and first-time password change users.
- Lease PDFs are rendered in browser-native iframes from server-generated URLs.
- Dashboard Creator scripts are validated by the API before create/update and before returning AI-generated code.
- Dashboard Creator execution in
admin-panelandsuperadmin-panelruns in a dedicated Web Worker with a 3-second timeout and runtime shadowing for network, storage, DOM, dynamic-code, messaging, and browser-side exfiltration APIs. The worker is terminated after every execution.
MTR-1001 frontend sink review:
admin-panel/src/components/ui/chart.tsx,guest-app/src/components/ui/chart.tsx, andsuperadmin-panel/src/components/ui/chart.tsxusedangerouslySetInnerHTMLonly to emit CSS custom properties from local chart config. No user HTML is injected through those sinks.landing/src/pages/OwnerProperties.tsxno longer renders owner biography as HTML; it strips markup and renders text, matching the property detail page.admin-paneliframe usage is limited to lease/template PDF previews from server-generated URLs. Those views should remain PDF-only; do not point these iframes at user-controlled HTML.
Deployed header inventory checked on 2026-05-19:
| App | Environment | CSP | HSTS | X-Frame-Options | Notes |
|---|---|---|---|---|---|
admin-panel | dev/prod | absent | absent | absent | DigitalOcean static site behind Cloudflare. |
guest-app | dev/prod | absent | absent | absent | DigitalOcean static site behind Cloudflare. |
superadmin-panel | dev/prod | absent | absent | absent | DigitalOcean static site behind Cloudflare. |
landing | dev/prod | report-only after MTR-1001 deploy | absent | absent | Express SSR service now disables X-Powered-By and sets baseline response headers. |
Recommended next header step: add matching response headers for the static frontends at the deployment edge and promote CSP from report-only to enforcing after validating custom domains. A strict enforcing CSP must account for Google Fonts, Google Maps, Sentry, Stripe, PDF previews, and the Dashboard Creator worker execution model.
Logging and Audit Trail
Core actions are logged through core/libs/logger.ts into the logs
collection. Security-relevant events include:
- user creation, login, password recovery, password reset, activation, and impersonation token generation.
- team and membership changes.
- API key creation/revocation/deletion.
- file uploads and private file accesses.
- payment lifecycle events.
- Stripe account lifecycle events.
- conversation message delivery, retry, edit, and delete events.
- task assignment and assignee notification events.
Production error responses redact internal reason, stack, and trace
fields.
MTR-990 Audit Remediations
The first audit pass for MTR-990 fixed these issues:
POST /users/createnow rejectsrole: "admin"unless the internal secret is supplied. Previously a missing secret was accepted because only the wrong-secret path was blocked.POST /users/change-passwordnow requires an authenticated matching user session, or a superadmin session.POST /guests/change-passwordnow requires an authenticated matching guest session.POST /guests/change-password-first-timenow requires an authenticated matching guest session.- User-controlled message bodies in conversation replies, inbound SMS/email webhook notifications, task-assignee emails, manual payment reminders, and campaign emails are now HTML-escaped before being inserted into email HTML.
- Production-code dynamic imports found during the audit were removed from the admin report PDF export and the Furnished Finder profile diff script.
POST /users/send-verification-codeandPOST /users/verify-codenow have route-level rate limiters plus per-user email/mobile attempt counters.pdf-converterconversion endpoints fail closed withoutAPI_KEYunlessPDF_CONVERTER_ALLOW_UNAUTHENTICATED_LOCAL_DEV=trueis set explicitly, enforce request/upload/payload size limits, and reject remote resources in WeasyPrint HTML-to-PDF inputs.url-shortenernow validates redirect targets before creation and before redirecting stored links. Targets must usehttps, contain no URL credentials, and match the explicit payment-link host allowlist.
Regression tests were added for these paths.
Active Audit Backlog
The following items remain open after the first MTR-990 pass:
- Review every route still using hand-written authorization instead of
requireResourceAccess()and migrate high-risk resources first. - Implement frontend security headers after validating CSP in report-only mode.
- Continue dependency and container audits for
core,pdf-converter,ff-scraper, and frontend builds. - Continue periodic workspace hygiene sweeps so local tool-history and agent-settings files do not retain database/provider credentials.
Dependency Audit Snapshot
Production dependency audits were run on 2026-05-18.
Clean production audit:
coreurl-shortenerguest-applandingautomationmcpvirtual-otpff-scraperdocsportable-reputationprototypes/lease-flow-co-pilotprototypes/lease-leap-app
Remediated production dependency findings:
admin-panel,guest-app, andsuperadmin-panel: directjspdfdependency upgraded from3.0.3to4.2.1, removing the critical jsPDF advisories from the production audit.- MTRO-managed JavaScript packages have been standardized on
pnpm; npm lock files remain only in vendored OpenZeppelin contract dependencies. admin-panel,guest-app,superadmin-panel, andlanding: React Router and@remix-run/routerare pinned or overridden to patched releases.admin-panel,guest-app, andsuperadmin-panel: vulnerablelodash,lodash-es, DOMPurify, Tailwind/PostCSS transitive packages, and globbing transitive packages are patched through direct upgrades orpnpm.overrides.automation,mcp,virtual-otp, andff-scraper:axios,follow-redirects, SDK, and routing transitive findings are patched through direct upgrades orpnpm.overrides.docs: Docusaurus/Webpack audit findings are patched throughpnpm.overrides; Webpack is pinned to5.105.4for Docusaurus 3.9.2 compatibility.
Open production dependency findings:
admin-panelandsuperadmin-panel:react-quilldepends onquillversions affected by a moderate XSS advisory. The advisory has no patchedquillrelease in the current dependency line (patched_versions <0.0.0), so this requires replacing or isolating the rich-text editor rather than a lockfile patch.
These findings are dependency-maintenance work. They are separate from the backend authentication fixes applied during MTR-990.
Developer Workspace Secret Hygiene
Developer workspaces often accumulate tool state outside the application repositories. These files can contain commands, pasted payloads, model context, or shell snapshots that include credentials even when the repository itself is clean.
Local-only tool state must not be committed, synced, archived, or copied into issue comments. The current ignore policy excludes:
.claude/.codex/.agents/.grog/.env.*files, while keeping.env.exampletrackable.*.local.json
Expected handling workflow:
- Store real credentials only in the relevant secret manager or intentionally local runtime files that are ignored by git.
- Do not paste production database URIs, provider API keys, webhook signing secrets, private keys, or session tokens into agent prompts, Linear comments, GitHub comments, or generated docs.
- Before archiving or syncing a workspace, delete local tool-history/cache
directories such as
~/.claude/file-history,~/.claude/projects,~/.claude/paste-cache,~/.claude/shell-snapshots,~/.codex/sessions,~/.codex/history.jsonl, and transient.tmptool caches. - Run a redacted secret scan that reports file paths and match classes only; never paste detected secret values into tickets or chat.
- If a real credential is found in a tracked file, external comment, shared archive, or any other non-local medium, rotate it immediately in the source provider and update the secret manager reference.
MTR-999 cleanup note: local agent history/cache files were purged on 2026-05-18. No tracked MTRO repository file contained a real provider token or database credential requiring rotation; active local-only tool credentials were left in place and must stay out of archives.
Operational Checklist
Before production deploys:
- Confirm
SECRETis set and consistent only within the intended environment. - Confirm
CORS_ORIGINSor frontend URL env vars are populated. - Confirm
PDF_CONVERTER_API_KEY,FF_SCRAPER_API_KEY, and shortenerAPI_KEYare set. - Confirm Resend and Twilio webhook secrets are set.
- Confirm Stripe keys are environment-specific.
- Confirm DigitalOcean Spaces credentials are stored as secrets.
- Confirm
RATE_LIMIT_DISABLEDis not set in production. - Confirm logs and Sentry do not include raw passwords, tokens, or provider secrets.
- Confirm local tool configuration and shell history do not contain production database URIs or provider tokens.