Security fixes are applied to main and ship in the next tagged release. The
committed support period (a CRA Article 13 requirement) is five years from
the first stable release (0.7.0), with security fixes delivered on the
stable release channel for that period; on the edge channel the latest tagged
release remains the supported version. The commitment is recorded in
docs/POLICY.md ("Support period") and in the Declaration of Conformity
(docs/DECLARATION-OF-CONFORMITY.md); the period's start date is fixed when
the 0.7.0 stable release is cut.
To report a security vulnerability, please do not open a public GitHub issue.
Preferred channel: GitHub's private vulnerability reporting at https://github.com/OpenDigitalCC/lazysite/security/advisories/new
Please include:
- A description of the vulnerability.
- Steps to reproduce (or a proof-of-concept).
- Assessment of the potential impact.
- Any suggested fix or mitigation.
We aim to acknowledge reports within 48 hours and to provide a fix timeline within 7 days for critical issues.
In scope:
- The Perl scripts in this repository (
lazysite-*.pl,tools/*.pl). - The shipped Apache vhost templates under
installers/. - The default manager view template.
Out of scope:
- Vulnerabilities in Perl itself or in CPAN modules listed under
"Non-core dependencies" in
docs/architecture/code-quality.md. Please report those upstream. - Misconfiguration of an operator's web server or DNS.
- Browser-level vulnerabilities where lazysite's headers are the same as the wider web-server defaults.
The structured threat model (STRIDE, with OWASP ASVS L1 control mapping) is at
docs/SECURITY.md; the mechanism-level security narrative is
at docs/architecture/security.md.
Key operational points (full detail in docs/architecture/security.md):
- Strip client-supplied auth headers at the web server edge.
Add
RequestHeader unset X-Remote-User,X-Remote-Groups,X-Remote-Name,X-Remote-Email,X-Payment-Verified, andX-Payment-Payerto the vhost. The Hestia and Docker installer templates include this. - A connector is a disclosure you configured (SM579). Site data leaves
the instance only through a connector you defined with
manage_connectors, under a secret held engine-side (lazysite/connectors/secrets.json, 0600). A public form may send through one only if the connector opts in (public: 1, off by default); bound the fields such a form sends (fixed choices, not free text). Every call is audited by connector, trigger, mode and data class - never the payload - and rate-capped per connector. - The Odoo extension's connection user holds an Odoo API key (SM747). It is
set and cleared with
manage_connectors(odoo-secret-set,odoo-secret-clear) and written alone in its own file underlazysite/odoo/keys/,0600from creation in a0700directory; no action, tool or page returns it and it is not logged. Every operation the extension runs - for a page, a form, the schedule or an agent - runs with that account's rights in Odoo, so give the connection user only the rights the site's operations need: lazysite narrows nothing Odoo allows it. - The Odoo visitor key is a bearer secret (SM747). A visitor who is not
signed in is identified by 256 random bits the server mints, carried in the
lzs_odoo_visitorcookie (HttpOnly,SameSite=Strict,Secureover HTTPS, 30 days) and signed with the extension's own secret,lazysite/odoo/visitor.secret(0600). A key the server did not mint names no visitor. Records carry a hash of the key, never the key. Deletingvisitor.secretinvalidates every visitor key and orphans what was keyed by them. - An Odoo binding can let an anonymous visitor write to Odoo (SM747). A
binding with
publicin its modes, a writing operation andapply: immediateapplies the write in the visitor's request with nobody confirming. It is off unless the binding says all three, andodoo_allow_writesis off by default. What it can change is bounded by the connection user's rights and the operation's declared scope. The engine requires the visitor token (bound to the visitor key) on every POST, takes from the request only the plain-value parameters the binding maps, passes the visitor key only through the binding'svisitor_param, and caps attempts per visitor per hour (rate_per_visitor_hour, default 20) and per binding per hour (rate_per_hour, default 200); a cap whose count cannot be read refuses. Set the caps to what the operation should see. - An MCP grant can run as its own Odoo account (SM747).
odoo-secret-setwithgrantandloginstores that Odoo login's key for the grant, as the connection user's key is stored; the grant's MCP calls then run with that account's rights in Odoo instead of the connection user's. When the grant map cannot be read, the grant's calls are refused rather than run as the connection user. - A form that acknowledges the submitter mails an address a stranger chose
(SM877). An email handler writes only to the address you configured until
you set
mail_the_submitter_field; from then on the site also sends a separate message to whatever that form field holds. That is deliberate and it is an open-relay surface, so it is capped on the two axes that matter - per recipient and per site, both per hour,submitter_per_destination_hour(3) andsubmitter_per_site_hour(60). The form's own rate limit does not protect this: it is keyed per source IP, so it bounds how often one sender submits and says nothing about how many destinations the site will write to, and the party being harmed is the recipient. A cap that cannot be counted - because the record will not open - refuses the acknowledgement rather than treating an unreadable counter as an empty one. Your own notification always goes, and anything refused is recorded against that delivery in the audit trail. - Grant manager access through groups. Manager access is
carried by groups only: the
uicapability admits a group's members to the manager UI, andmanage_userscarries the operator powers. Both are grants inlazysite/auth/groups-settings.json, edited on the manager Groups page (perl tools/lazysite-users.pl setup-sysop --user NAMEseeds a correctly-granted admin group). When no group grants manager access, the site is in unsecured/dev mode and any authenticated user is treated as a manager (a DEBUG-level log line is emitted in that case). The legacymanager_groups:conf key was retired in 0.6.5 (SM138) with an automatic migration - seeUPGRADE.md. - Set a password for every user who might ever connect from
anything other than localhost. Empty-password accounts only work
from
127.0.0.1/::1, but a user that exists must be password-protected before the site is exposed. - Use HTTPS in production. The auth cookie's
Secureattribute is only emitted when$ENV{HTTPS}is set; over plain HTTP the cookie is stillHttpOnly; SameSite=Lax, but theSecureattribute and the trustedStrict-Transport-Securityheader require a TLS-terminated deployment. - Rotate the installation HMAC secret to invalidate every
outstanding session. Use the "Log out all users" button on the
manager Users page, or rewrite
lazysite/auth/.secretwith fresh random bytes by hand. This is the server-side lever for mass logout; seedocs/architecture/security.mdunder "Session revocation".
For questions about the security model that are not vulnerability reports, please open a regular GitHub issue or start a discussion.