Security model (operator’s view)
What this app enforces, what it trusts, and what residual risk remains. This is a deployment-level description, not the internal design record.
What the app enforces
The whole ceremony is server-side and browser-bound. Challenges are CSPRNG-generated, stored server-side, and matched against exactly one server record scoped to a single ceremony type — a login challenge can never satisfy a registration or a confirmation. The challenge TTL tracks the client timeout (≈300 s for login/registration, 180 s for action confirmation).
Single-use challenges, fail-closed. Each ceremony state is consumed atomically on the first verify attempt (exactly one caller wins across workers). A consumed, expired, or evicted state yields a uniform typed error, never a silent pass. A wrong passkey at the second factor re-arms a fresh state up to a small cap rather than burning the only 2FA state.
This uniform-error contract assumes System Settings → Allow Error Traceback is off. Frappe v15 ships it defaulted on; production sites must disable it or every 401 includes the traceback.
Guest login-CSRF defence (binder cookie). Because the attacker in a login-CSRF
is the ceremony’s own legitimate starter, a browser-bound token is the only
defence. Guest ceremonies set an ephemeral Secure; HttpOnly; SameSite=Lax
cookie and store only its SHA-256 in the record; verify fails closed unless the
request carries the matching cookie. An attacker cannot set or read this HttpOnly
cookie cross-site. Authenticated ceremonies bind to the session id instead.
Origin and RP-ID pinning, exact-match, fail-closed. RP ID and origins are
resolved from administrator-controlled configuration — never from live Host
or X-Forwarded-* headers. Origins are an exact-match allowlist including port;
the library checks the assertion’s origin against that list. A compatible host_name contributes
its own exact origin and explicit Passkey Origins add to it; RP ID alone never authorizes
https://<rp_id>. Settings saves require a non-empty resolved web-origin set. iOS must therefore have its
asserted https://<rp_id> origin explicitly present when the site’s host_name does not contribute
that exact value. The request host is re-checked at both begin and complete.
Usable means enabled and made for the operational RP. A different or blank credential RP ID
is stale; a disabled current-RP row is not stale. The operational RP is null when no RP resolves
or no canonical in-scope web origin or valid Trusted App Origin remains. This decision uses only
settings and host_name, never request headers or form parameters. Runtime web-origin checks
accept canonical HTTP or HTTPS origins in RP scope; HTTPS remains the settings-save rule (with
the developer localhost exception). An HTTP path counting as operational does not make it a
browser secure context.
When no operational path exists, passkey login discovery is disabled without an Error Log,
registration and passkey confirmation refuse, enrollment enforcement/nudges pause, and no
WebAuthn Signals are sent. Passkey-only and passkey-second-factor vetoes stop applying to users
without usable keys, including the OAuth password-grant guard. Core disable_user_pass_login
still applies. If a valid app path survives, the RP stays operational and the vetoes remain for
users with usable keys, even when the configured website has no passkey path. These are
administrator-controlled changes, exposed through the manager’s banner and posture panel.
Ceremonies re-check their recorded RP before credential insertion or session/grant minting;
missing or changed RP returns CeremonyExpired. Second-factor verification checks before
re-arming too, so an RP change yields no retry state. Discoverable login with an existing
credential made for another RP returns CredentialNotForThisSite, never a browser signal to
forget an unknown credential.
See the concurrency limits below.
Cross-origin rejection is enforced by the app. py_webauthn 2.8 accepts
crossOrigin: true and never parses topOrigin, so the app parses the raw
client data itself and refuses any cross-origin ceremony. This is the sole
enforcement of the “no cross-origin iframe ceremonies” rule.
User-verification policy, per ceremony. A passwordless first-factor login completes only when the authenticator actually verified the user (UV bit set) and that credential’s UV was initialized with a second factor — a bare UV assertion against an uninitialized credential is routed to a one-time password-backed setup step, never straight to a session. A passkey action confirmation always requires UV (a password confirmation, where the action allows one, is a separate method). A second-factor assertion after a correct password does not: the password already proved knowledge, and the passkey adds possession. UV is read from the assertion, never assumed.
Sign-count and backup-flag policy. Counters are stored and checked app-side: an exact non-zero replay is always rejected; a regression is flagged (owner emailed on the first flag) or hard-rejected under the Hard-fail knob; the counter is never written downward. Backup-eligibility is write-once at registration — a later mismatch fails the ceremony.
Global credential uniqueness. credential_id_sha256 is a unique index;
duplicate registration and cross-account credential hijack both fail closed at
insert.
Assertions must belong to the expected user. A passwordless login resolves the account from
the assertion’s userHandle and requires the credential to belong to it. When the user is known
before the ceremony (the second factor, action confirmation), the credential must be in that
ceremony’s allow-list and owned by the user, and a returned userHandle must be that user’s handle
(WebAuthn L3 §7.2).
Session minting through one choke point. Every passkey session is created via
core’s login_as → post_login — full login hooks, IP/hour checks, a fresh
session and CSRF token, and an Activity Log row. The app never spins up a fresh
login manager mid-flow.
Second-factor enforcement covers alternate login paths. When Passkey as Second Factor is on and
a user has a usable credential, the final on_login hook vetoes password, email-link,
social/OAuth, LDAP, and other core login completions unless this app has completed the passkey step.
The only downgrade is an enabled OTP fallback initiated by this app: fallback_to_otp issues a
short-lived, one-time marker bound to core’s tmp_id, and the hook consumes it only on core’s login
route after core accepts a non-empty OTP. Carrying that tmp_id on an email-link or OAuth request
does not consume or satisfy it. Calling core’s OTP path directly creates no marker and remains blocked.
Administrator exemption is narrow. Administrator remains break-glass exempt from the per-user
Passkey Only Login veto. Administrator is not exempt from the enrolled second-factor rule:
once a usable credential is explicitly enrolled while Passkey as Second Factor is active, the
same alternate-path veto applies. Core’s site-wide disable_user_pass_login also has no
Administrator exemption. Passkey enrollment enforcement likewise keeps System Manager users in
scope by default. A System Manager handles enrollment recovery on the affected
User → Passkeys section, using a grace reset or temporary per-user exemption.
If no manager can sign in, restore one trusted manager before considering site-wide
changes; see recovery.
Hardening Administrator
Frappe core has no switch that disables Administrator’s password. Administrator is break-glass: the per-user “Passkey Only Login” flag does not apply, and a password still works while site-wide password login is on and Administrator has no usable passkey under Passkey as Second Factor.
Use named System Manager accounts day to day. Enrol a passkey for Administrator with Passkey as Second Factor on. Restrict Administrator by IP (User → Restrict IP). Set notify_admin_access_to_system_manager in site config so System Managers are emailed when Administrator logs in. Keep a strong, vaulted password. Recovery is in recovery.md.
Password rotation is checked at the session boundary. Every password-to-passkey ceremony stores
a keyed, non-reversible version of the password hash at leg one and compares it with the current
version before minting a session. A password change during the ceremony fails closed. The marker
does not expose the password hash. The plaintext password rides the Redis ceremony record (TTL 300 s)
on the second-factor leg only when passkey_2fa_allow_otp_fallback=1 (the default) and the user is
OTP-capable, matching core’s cache_2fa_data, or for an external-auth user without a local password
hash because core must re-authenticate it before session minting. Mode enablement and local-password
ceremonies use Frappe core’s get_encryption_key(), which creates and saves a key when
site_config.json has none; an empty encryption_key entry fails closed.
Password-reset keys rotate a password but do not satisfy a passkey factor. Frappe core calls
login_as after a successful reset. For an enrolled second-factor user, the app lets the reset
transaction finish but downgrades that automatic login to Guest; the user must then sign in with the
new password and passkey (or an explicitly enabled OTP fallback). Passkey-only accounts keep their
stricter veto.
Passkey failures share core’s consecutive-login tracker. Once a credential resolves an account, invalid assertions feed Frappe’s tracker. A locked account cannot bypass that lock with a valid passkey, and a successful verified passkey resets the same consecutive-failure state.
Action-confirmation grants are tightly bound. A grant from the
@passkey_protected primitive is single-use, ~180 s, and bound to
user + session + action + payload. Before any grant is read, the call is bound to the full
signature; a call that would not bind (a missing or unexpected argument) is refused and no grant
is consumed. The payload holds each name in bind_params: a named parameter binds its value
(defaults applied), a *args / **kwargs parameter binds the whole tuple / mapping, and any other
name binds that key of the **kwargs mapping only when the call passes it, so pay() and
pay(amount=None) never share a grant. A bound name that also arrives as a **kwargs key (a
positional-only, *args or **kwargs name passed by keyword) is ambiguous and refused. Arguments
left out of bind_params are not bound. A protected action requires a passkey by default; a
password confirmation is accepted only when the action opts in with allow_password_fallback=True.
allow_sudo_window=True (off by default) lets a live full-sudo window satisfy the gate without a new
gesture.
Tokens are returned once and stored
only as SHA-256, so a cache snapshot yields nothing usable. The grant is consumed
before the protected function runs (one gesture = one attempt), and the payload
hash is always computed server-side with a pinned canonicalization — the client
never computes a hash. A presented grant is consumed before checking its payload binding:
a mismatched payload spends the token and cannot authorize the action through that grant.
A live sudo window can still authorize it when allow_sudo_window=True; otherwise, confirm again
before retrying. The signature-validation refusals described above happen before consumption.
The action↔challenge binding replaces the retired
txAuthSimple extension: the signature commits to a challenge that names exactly
one action and payload.
The decorator publishes its static policy to site-scoped Redis before returning the initial 401, so
begin_confirmation and password re-auth can land on another worker without changing the offered
methods or display metadata. Unknown actions fail closed without a password fallback; the protected
method’s consumer remains the final method and payload authority.
The “sudo” re-auth window (default 600 s) gates the app’s own management surface (add/delete passkeys). It is seeded by a fresh interactive login or a password / passkey re-auth. An “external” login (email link, social) seeds only the restricted first-passkey bootstrap when passwordless passkey login is enabled, never general management power.
An impersonated session is read-only for passkeys. Core’s “Impersonate” is open to the
Administrator and, on Frappe v16, to any role granted the User impersonate permission. The lock
stops accidental or hidden changes to the user’s passkeys from that session. It is not a boundary
against a determined administrator, who can do the same from their own session. Reads still answer.
- App endpoints. Registering, renaming or removing a passkey, the passkey-only switch, a
confirmation or password re-auth, a
@passkey_protectedaction, the nudge and enforcement events, bulk stale removal, and the per-user exemption and grace reset refuse withImpersonatedSessionRefused(403), before any rate limit, so the session never spends the user’s budgets, prompts or grace logins. - The passkey DocTypes. WebAuthn Credential, WebAuthn User Handle, Passkey Settings and Passkey
Enforcement Role refuse the session in their controllers, with the same error, on insert, save,
delete (the Passkey Settings Single included), rename (
validate_rename=falseincluded) and, on v16, discard. That covers every write that goes through a document, from Desk,frappe.client,/api/resourceor/api/v2, whoever is impersonated, the Administrator included. - The UI. The boot payload carries
impersonated, and the Desk and portal then show the passkeys read-only, with no nudge or enforcement prompt. The User form hides its enforcement recovery buttons and its link to the credential list.
Not covered, by design:
- An administrator who knows or resets the user’s password (or mints API keys) and signs in normally. That is an ordinary login, not an impersonated session.
- Frappe’s background bulk delete and edit (a list delete of more than 10 rows, a bulk edit of 20 or more) and other admin tooling that runs as a background job, such as a Scheduled Job Type or, once enabled, Data Import. The job does not carry the impersonation.
- Comments, likes, tags, shares, assignments and attachments on passkey records. They are metadata, not passkey state.
- Deleting or renaming the User, which cascades to that user’s passkeys.
Password classification and OTP-fallback acceptance follow command dispatch precedence. A truthy
cmd must be exactly login; only a request without a diverting command may rely on the canonical
/api/method/login path. Redeeming an email-link key through that URL remains an external login and
cannot spend an OTP fallback marker.
Security invariants are transactionally locked. Authentication locks the user and credential
before counter/UV updates; registration locks the user, handle, and credential census before cap
enforcement and insertion; credential deletion and passkey-only toggles share a locked login-floor
census; and mode-setting changes lock the Single rows before checking passkey-only users. The
Passkey Settings and System Settings floor validators (second factor needs core 2FA; second factor
cannot be the only passkey mode while password login is off) lock the Passkey Settings rows and read
the other document’s values with locking reads. Frappe locks each Single’s own rows before
validate, so two such saves at the same moment can meet in opposite order; InnoDB then aborts one
as a deadlock and the admin retries it. This prevents concurrent workers from committing
individually valid reads into an invalid combined state.
Enrollment grace is once per session. A user/session digest is claimed atomically in Redis, so retries or multiple tabs can consume at most one grace defer for that session.
Uninstall exports fail closed. Credential export schema v2 is site-bound and authenticated with
HMAC-SHA256 derived from the site’s encryption_key, written atomically at mode 0600. Import
rejects another site, a bad signature or an unsigned file. Its default requires empty passkey
tables; allow_existing=True is an explicit reviewed merge with row-level rejection reporting.
Enrollment cadence is server-owned. Nudge events and grace deferrals lock the User row before reading the current DefaultValue row with a locking read, bypassing cached values and stale transaction snapshots. Concurrent nudge events preserve opt-out and deferrals preserve spent grace. Degrade to Nudge honors the same opt-out, cap and cooldown on Desk and portal; renaming a User preserves these budgets.
Native dormancy requires an explicit contract. Fresh installation is blocked by any
frappe.passkey module to avoid installing two authorities. An already-installed app no-ops hooks
and returns 417 PasskeyServedByCore only when core defines the exact marker
FRAPPE_PASSKEYS_APP_HANDOVER = "frappe-passkeys-app-handover-v1". Module presence alone is not
accepted as evidence of a complete or safe handover.
Password-oracle throttling on the password-taking endpoints the app adds beyond core’s login surface (the uv-setup step and the confirmation password fallback): a per-user failure counter locks after 5 failures for 15 minutes, independent of core’s login tracker. The primary login leg keeps core’s own protections only, to avoid an account-lockout denial of service.
Rate limits, keyed two ways. Pre-session (guest) endpoints keep core’s own
@rate_limit decorator, which keys on IP — the only stable identity before a
session exists. Every authenticated endpoint instead uses a per-user app
counter (a session-user-keyed cache token), because core’s IP keying would 429 a
whole NAT’d office off one busy user and can’t attribute abuse to an account. A
guest ceremony can never reach the per-user endpoints, so the two classes don’t
overlap. Authenticated endpoints check the session (and refuse an impersonated
session’s writes) before they count a call, so an unauthenticated request or an
impersonated write never spends a user’s budget; the counter still runs before any
single-use state is consumed.
complete_uv_setup re-checks that passwordless login is on before it spends a
password attempt.
Guest / pre-session endpoints — core @rate_limit, IP-keyed:
| Endpoint | Limit |
|---|---|
begin_login |
30 / 60 s |
verify_login |
10 / 60 s |
complete_uv_setup |
5 / 300 s |
login_with_password |
10 / 60 s |
verify_second_factor |
10 / 60 s |
fallback_to_otp |
5 / 300 s |
get_app_translations |
30 / 60 s |
assetlinks / apple_app_site_association |
120 / 60 s |
Authenticated endpoints — per-user app counter, keyed on the session user:
| Endpoint | Limit |
|---|---|
begin_registration |
20 / hour |
verify_registration |
20 / hour |
list_credentials |
60 / min |
rename_credential |
20 / hour |
delete_credential |
10 / hour |
set_passkey_only_login |
20 / hour |
get_signal_data |
60 / min |
record_nudge |
30 / hour |
record_enforcement |
30 / hour |
begin_confirmation |
30 / 5 min |
verify_confirmation |
30 / 5 min |
reauth_password |
5 / 5 min |
get_resolved_rp_id |
30 / min |
get_security_posture |
30 / min |
get_settings_context |
30 / min |
recalculate_enforcement_preview |
3 / min |
get_rp_id_change_impact |
30 / min |
remove_stale_passkeys |
3 / min |
get_user_enforcement_admin |
60 / min |
set_user_exemption |
30 / hour |
reset_enforcement_grace |
30 / hour |
The action-confirmation endpoints (begin_confirmation / verify_confirmation /
reauth_password) require an authenticated caller, so they sit in the per-user
class — the reauth_password 5 / 5 min ceiling is a second, coarse backstop on
top of the per-user password-oracle lock above.
The Passkey Settings and enforcement-admin endpoints additionally require System Manager.
All use POST and return their payload under message. Settings endpoints live in
passkeys.passkeys.doctype.passkey_settings.passkey_settings; enforcement endpoints live in
passkeys.enforcement_admin. The preview reads do not mutate credentials. Bulk stale removal
refuses impersonation, serializes with settings saves, keeps flagged rows and uses normal delete
hooks. See the endpoint contract.
Per-user exemption and grace reset mutate enrollment authorization state and are limited to
30/hour.
| Read | Payload |
|---|---|
get_resolved_rp_id() |
{rp_id, site_rp_id, configured_site_origin} |
get_security_posture() |
{verdict: {headline, tone, can_bypass, bypass_labels, degraded}, rows: [{code, severity, what, why, recommendation, detectable, bypass_label}]} |
get_user_enforcement_admin(user) |
{user, effective, enforcing, in_scope, exempt, grace_used, grace_total, grace_remaining, credential_count} |
get_user_enforcement_admin is a single-user detail read, not a bulk-list API. Its
60/minute budget belongs to the calling administrator, across all target users; direct
Python calls spend the same budget and raise TooManyRequestsError at the limit.
No account enumeration. First-factor login is discoverable-only — it takes no identifier, so the begin response leaks nothing about which accounts have passkeys. Ownership checks on management endpoints return a uniform “not found” so one user cannot probe another’s credentials.
API keys and OAuth tokens
OAuth password-grant token creation is guarded before insertion, including on older supported
Frappe releases whose password-grant validator skips post_login. The guard shares the login
veto’s account policy: users with a usable passkey and either passkey-only login or passkey
second-factor requirements cannot mint a token using only a password. Site-wide password-login
disablement also refuses this grant.
The grant type comes from the request form consumed by core’s OAuth token endpoint;
authorization-code and refresh-token grants are unchanged.
Passkeys protect interactive sign-in only. Token-authenticated API requests (an API key and secret
sent as Authorization: token … or Basic …, or an OAuth Bearer token) are not subject to
passkey sign-in checks, even when enrollment is enforced, a user is Passkey Only, or password login
is disabled. Frappe’s own two-factor authentication behaves the same way. Treat API keys as
privileged credentials: issue them sparingly, rotate them, and revoke them when they are no longer
needed.
A user’s own passkey management and confirmation require a signed-in browser session. A
token-authenticated request gets a BrowserSessionRequired (403) refusal from those endpoints
(registration, credential list, rename and delete, the passkey-only switch, password re-auth, and
the confirmation ceremony) and from every @passkey_protected action. The System Manager
administration endpoints (per-user exemption and grace reset, settings reads and stale removal) accept a
System Manager token like the rest of Frappe’s administration API. A token caller can therefore
never open a sudo window or obtain or spend a confirmation grant, even with the user’s password.
The server recognizes such a request by its session: core binds a token caller with
frappe.set_user, which creates no session and sets the session id to the user name, whereas a
real sign-in gets a random session id.
What the app trusts
- The deployment terminates HTTPS at the public host and forwards the real origin. The app pins RP ID and origins to that host; if the proxy lies about the host, ceremonies fail closed rather than misbind, but correct operation assumes the public host is within RP scope and its exact origin is trusted. HTTPS/HSTS at the edge is the operator’s responsibility; settings saves refuse plain HTTP origins except localhost in developer mode. Runtime operational status is not an HTTPS health check.
- No untrusted content is served within the RP ID’s scope. A passkey is scoped to the RP ID; hosting attacker-controlled content on a subdomain within that scope is a deployment concern the app cannot enforce.
bench console/ raw DB writes are site-admin authority and bypass every validation guard here, by design (seeoperations.md). This is out of the threat model and is what makes lockouts recoverable.- Core’s own primitives — password checking, the login tracker, session and CSRF handling, and the OTP second factor — are used as-is, never reimplemented.
Residual risks
- An RP change can lower a user’s sign-in requirements. Stale-only users return to password (plus core OTP where required), unless core disables password login. The passkey-only flag persists and applies again with one usable credential. Retained keys work again if the original RP and origins return; deletion is irreversible. Plan re-enrollment for an intentional move.
- RP checks have a request-length visibility window. The lockout census and checks before insertion/session/grant minting read settings uncached but without locking them. A transaction may retain its earlier snapshot after an RP/origin change commits. A racing request can leave one stale row or mint a session/grant under the just-replaced RP.
- A simultaneous RP change and delete/disable can require System Manager recovery. A row
treated as stale under RP A can be removed just as RP B makes it current. With
disable_user_pass_loginon and no other login method, the affected user is stranded; follow recovery. Bulk removal alone locks settings before handles and credentials and reads the RP from that locked result; it can legitimately delete RP-B rows if it runs before the switch from A to B. - File configuration overlaps across requests.
host_namereloads per request, not at a restart boundary. In-flight requests can still see the old value, and a database lock cannot serialize a file change. For a strict RP/host cutover, stop traffic, drain requests, change configuration, then resume. - Recovery is part of the boundary. An account’s assurance is only as strong as its weakest fallback. Keep recovery flows at parity-or-stronger friction than the passkey itself; the account-recovery story is admin-mediated re-enrollment, not a weaker alternate login. Encourage users to enroll two passkeys.
- Alternate first factors are incompatible with second-factor-only enrollment. The final veto blocks social/OAuth, LDAP, and email-link completions for an enrolled user because those core paths cannot enter this app’s passkey leg. Keep passwordless “Login with Passkey” enabled for enrolled accounts that do not have a usable local password, or retain an operator recovery path.
- First-passkey enrollment can ride an external first factor (bootstrap). With
passkey_allow_first_enrollment_on_external_loginon (default), a session established through an external first factor — an email login link or social/OAuth sign-in — may enroll the account’s first passkey without a stronger re-auth. This solves the chicken-and-egg where a passwordless account has nothing stronger to authorize its first credential. The carve-out is deliberately narrow: it applies only while first-factor “Login with Passkey” is on, only to a user with zero usable credentials (every later add still needs a passkey- or password-seeded sudo window), and every use records apasskeys:external_login_enrollmentrisk event (operations.md). Turn offpasskey_allow_first_enrollment_on_external_loginto require a stronger factor for the first enrollment too. - A same-session password change does not revoke an already-live management sudo window (≤600 s). Password+passkey login ceremonies are different: they compare the keyed password-hash version before session minting and fail if it changed.
- A backup restored to a stale point resurrects revoked credentials — review
credential inventories after any restore (
operations.md). - The guest binder cookie is not
__Host-scoped. It isSecure; HttpOnly; SameSite=Laxbut carries no__Host-prefix, so a sibling subdomain of the site’s registrable domain (a compromised or attacker-controlled*.example.com) could plant apasskey_bindercookie the browser also sends here — cookie fixation. This does not defeat the login-CSRF defence: the cookie is HttpOnly and the ceremony matches its stored SHA-256, so an attacker who cannot read the victim’s value cannot complete a ceremony bound to it, and Frappe core’s own sessionsidcookie shares the identical exposure. The__Host-prefix would close it but requires an HTTPS scheme the app cannot assume — it is silently dropped onhttp://origins (dev benches, CI), which would fail login closed. - Cross-origin / multi-domain serving (Related Origin Requests) and identifier-first login are not implemented in this version.
- A console-created settings desync (e.g. passkey 2FA on while core 2FA off) is outside what validators can catch; the security posture panel flags it.
-
Requiring a passkey is a post-login interstitial, not an authentication block. The session already exists before the gate runs — it raises friction toward enrolling a passkey (and, once grace is spent, becomes a non-dismissible interstitial), but it does not turn passkeys into a server-side first-factor requirement. A client that never renders the app’s JS is not gated by it. The requirement drives adoption; it is the login modes (passkey first-factor / second-factor) that decide what actually authenticates a session.
- Frappe itself must be kept patched. The app declares a minimum Frappe version but does not check it at runtime; core’s own login surface (for example email-link login) is only as safe as the deployed Frappe.
- Attacker-reachable transitive parsers float without a lockfile. Client attestation is CBOR-
decoded (
cbor2) and attestation certificate chains are ASN.1-parsed (pyasn1). The app declareswebauthn==2.8.0andcbor2>=5.9.0;pyasn1arrives throughwebauthn, and a fresh resolve givespyasn1>=0.6.4. A stale bench may still carry an older, vulnerablepyasn1— runbench setup requirementsand confirm that minimum on the deployment bench. The app’s owncryptographyposture inherits Frappe’s pin (currently~=50); re-check it when Frappe’s moves.
Reporting a vulnerability
Follow ../SECURITY.md. Do not include vulnerability details in a public issue.
Issues independently affecting Frappe core should also follow Frappe’s own current responsible-
disclosure process; this app’s upstream proposal does not make the projects one security boundary.