Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ npm run quality # Full quality check (lint + types + tests)
Read before changing anything in the corresponding area:

- [`docs/architecture/email-trust-and-site-isolation.md`](docs/architecture/email-trust-and-site-isolation.md) -- how SSO email claims are verified and how site-level SSO trust is confined so a compromised site config cannot escalate to superadmin or cross-site takeover. Required reading for changes to auth providers, `cleanUser`, `authProviderLoginCallback`, `adminMode`, or the change-host flow.
- [`docs/architecture/emails.md`](docs/architecture/emails.md) -- the outbound email pipeline: the two `/api/mails*` endpoints, sanitization/escape at the trust boundary, MJML template substitution, and `sendMailI18n`. Required reading for changes under `api/src/mails/`, the MJML templates, the mail schemas, or any caller that posts to `/api/mails`.
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"license": "MIT",
"scripts": {
"dev": "mkdir -p ../dev/logs && NODE_ENV=development ENABLE_TEST_API=1 DEBUG=upgrade* node --watch --experimental-strip-types index.ts 2>&1 | tee ../dev/logs/dev-api.log"
"dev": "mkdir -p ../dev/logs && NODE_ENV=development ENABLE_TEST_API=1 IGNORE_ASSERT_REQ_INTERNAL=1 DEBUG=upgrade* node --watch --experimental-strip-types index.ts 2>&1 | tee ../dev/logs/dev-api.log"
},
"imports": {
"#config": "./src/config.ts",
Expand Down Expand Up @@ -53,6 +53,7 @@
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limiter-flexible": "^5.0.5",
"sanitize-html": "^2.13.0",
"@authenio/samlify-node-xmllint": "^2.0.0",
"samlify": "^2.11.0",
"seedrandom": "^3.0.5",
Expand Down
28 changes: 28 additions & 0 deletions api/src/mails/escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import sanitizeHtml from 'sanitize-html'

// HTML-escape policy: every tag is escaped to entities. Used by textToSafeHtml
// to turn caller-supplied plain text into safe-to-substitute HTML.
const textToSafeHtmlOptions: sanitizeHtml.IOptions = {
allowedTags: [],
allowedAttributes: {},
disallowedTagsMode: 'escape'
}

// HTML sanitization policy: conservative allow-list, http/https/mailto hrefs
// only. Used by sanitizeMailHtml on caller-supplied html for /api/mails.
const sanitizeMailHtmlOptions: sanitizeHtml.IOptions = {
allowedTags: ['a', 'b', 'i', 'em', 'strong', 'u', 'br', 'p', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
allowedAttributes: {
a: ['href'],
'*': ['style']
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesAppliedToAttributes: ['href'],
allowProtocolRelative: false
}

export const textToSafeHtml = (text: string) =>
sanitizeHtml(text, textToSafeHtmlOptions).replace(/\r?\n/g, '<br>')

export const sanitizeMailHtml = (html: string) =>
sanitizeHtml(html, sanitizeMailHtmlOptions)
23 changes: 10 additions & 13 deletions api/src/mails/router.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import config from '#config'
import { Router } from 'express'
import { reqSiteUrl, reqIp, reqUser, session, httpError, reqIsInternal } from '@data-fair/lib-express'
import { internalError } from '@data-fair/lib-node/observer.js'
import { reqSiteUrl, reqIp, reqUser, session, httpError, assertReqInternalSecret } from '@data-fair/lib-express'
import storages from '#storages'
import mongo from '#mongo'
import { RateLimiterMongo } from 'rate-limiter-flexible'
import emailValidator from 'email-validator'
import multer from 'multer'
import { reqI18n } from '#i18n'
import { sendMail } from './service.ts'
import { textToSafeHtml, sanitizeMailHtml } from './escape.ts'
import type { FindMembersParams } from '../storages/interface.ts'
import { reqSite } from '#services'

Expand All @@ -18,15 +18,8 @@ export default router
const upload = multer({ storage: multer.diskStorage({}) })

router.post('/', async (req, res, next) => {
const key = req.query.key
if (!config.secretKeys.sendMails || config.secretKeys.sendMails !== key) {
throw httpError(403, 'Bad secret in "key" parameter')
}
if (!reqIsInternal(req)) {
internalError('mails-send', 'Trying to send mails from an external request')
// TODO: make this blocking in a coming release
// throw httpError(403, 'Forbidden')
}
if (!config.secretKeys.sendMails) throw httpError(403, 'sendMails secret is not configured')
assertReqInternalSecret(req, config.secretKeys.sendMails)
next()
}, upload.any(), async (req, res) => {
const mailBody = (await import('#types/mail/index.ts')).returnValid(typeof req.body.body === 'string' ? JSON.parse(req.body.body) : req.body)
Expand Down Expand Up @@ -76,13 +69,17 @@ router.post('/', async (req, res, next) => {
}
}

const htmlMsg = mailBody.html
? sanitizeMailHtml(mailBody.html)
: textToSafeHtml(mailBody.text ?? '')

results.push(await sendMail([...to].join(', '), {
replyTo: mailBody.replyTo,
host,
path,
subject: mailBody.subject,
text: mailBody.text ?? '',
htmlMsg: mailBody.html ?? mailBody.text ?? '',
htmlMsg,
htmlCaption: ''
}, attachments))
}
Expand Down Expand Up @@ -138,7 +135,7 @@ router.post('/contact', async (req, res) => {
path: site?.path,
subject: req.body.subject,
text,
htmlMsg: text,
htmlMsg: textToSafeHtml(text),
htmlCaption: ''
})
res.send(req.body)
Expand Down
3 changes: 3 additions & 0 deletions api/src/mails/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export const getI18NParams = (key: string, messages: any, params: SendMailI18nPa
export const sendMailI18n = async (key: string, messages: any, to: string, params: SendMailI18nParams) => {
if (params.link) {
const linkUrl = new URL(params.link)
if (linkUrl.protocol !== 'http:' && linkUrl.protocol !== 'https:') {
throw new Error(`refusing to send mail with non-http(s) link: ${linkUrl.protocol}`)
}
params.host = linkUrl.host
params.origin = linkUrl.origin
params.path = linkUrl.pathname
Expand Down
1 change: 1 addition & 0 deletions api/src/test-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ router.delete('/', async (req, res) => {
}
await mongo.passwordLists.deleteMany()
await mongo.db.collection('sd-rate-limiter-auth').deleteMany()
await mongo.db.collection('sd-rate-limiter-contact').deleteMany()
const { getSiteByHost } = await import('./sites/service.ts')
getSiteByHost.clear()
// Force a fresh SAML cert mint on the next request — exercises createCert end-to-end
Expand Down
183 changes: 183 additions & 0 deletions docs/architecture/emails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Email send pipeline

How simple-directory accepts, sanitizes, templates and dispatches outbound
email. Required reading before changing anything under `api/src/mails/`,
either of the two `/api/mails*` routes, the MJML templates, or
`sendMailI18n`.

See also [`./email-trust-and-site-isolation.md`](./email-trust-and-site-isolation.md)
for how SSO email *claims* are verified — orthogonal to this doc, which
covers the SMTP-send path only.

## Why this surface is sensitive

The mail pipeline takes content from three populations of callers:

- **Internal services** (events, future others) post arbitrary `html` to
`POST /api/mails` over a shared secret.
- **Anonymous web visitors** post arbitrary `text` to
`POST /api/mails/contact` through any portal's contact form.
- **simple-directory itself** sends i18n-templated mail via `sendMailI18n`
(signup, login, invitations, planned-deletion, …).

All three feed a single MJML pipeline in `api/src/mails/service.ts` that
does plain `String.replace`-style substitution before MJML parses. Any value
substituted into a template is HTML in the recipient's mail client; values
that reach `href=` / `src=` attributes are URLs in the recipient's mail
client. The trust boundary therefore lives at the **values**, not at the
template.

## Pipeline

```
caller payload
endpoint ─ schema validation (api/types/mail/schema.js, api/contract/contact-mail.ts)
│ ─ auth / rate-limit (assertReqInternal + secret, or session+IP-rate)
escape / sanitize at boundary (api/src/mails/escape.ts)
sendMailI18n (optional layer 1) microTemplate(messages.mails[key][k], i18nParams) ─ api/src/mails/service.ts
│ fills {host}, {origin}, {link}, {contact} into the i18n strings
sendMail (layer 2, always) microTemplate(template, tmplParams) ─ api/src/mails/service.ts:114
│ fills {htmlMsg}, {htmlCaption}, {htmlButton}, {htmlAlternativeLink}, {link}, {logo}, theme.* into the MJML
mjml2html (MJML → HTML)
nodemailer transport (api/src/mails/transport.ts)
```

`microTemplate` is `@data-fair/lib-utils/micro-template.js`, a literal
`String.replace` over `{key}` patterns — **no escaping**. The escape/
sanitize step in front of it is the only thing that keeps caller-controlled
HTML from reaching the recipient.

## Endpoints

### `POST /api/mails` — internal-only, secret-key gated

- File: `api/src/mails/router.ts:20-90`.
- Body: `api/types/mail/schema.js` — `to`, `subject`, `text?`, `html?`,
`replyTo?`, `sender?`.
- Auth: `assertReqInternalSecret(req, config.secretKeys.sendMails)` (from
`@data-fair/lib-express`). That helper enforces both the internal-origin
check **and** the secret in one call, accepting the secret via the
`x-secret-key` header (preferred) or the legacy `?key=` query param
(kept as a deprecated fallback that logs a warning, so existing callers
keep working while they migrate). The internal-origin check is bypassed
in dev/test by `IGNORE_ASSERT_REQ_INTERNAL=1` (set by `npm -w api run dev`
and `tests/support/in-process-server.ts`); in production it is
unconditional.
- Sanitization of the `htmlMsg` value substituted into the MJML template:
- `body.html` present → `sanitizeMailHtml(body.html)` — allow-list of
safe tags, `href` restricted to `http` / `https` / `mailto`.
- `body.html` absent → `textToSafeHtml(body.text)` — HTML-escape + `\n`→`<br>`.
- The plain-text part sent to nodemailer is `body.text` unmodified
(recipients on text-only clients see what the caller composed).

### `POST /api/mails/contact` — anonymous, rate-limited

- File: `api/src/mails/router.ts:94-145`.
- Body: `api/contract/contact-mail.ts` — `from` (email), `subject`,
`text`. **No `html` field by schema.**
- Auth: enabled only if `config.anonymousContactForm`; requires
`req.body.token` (an anonymous-action token, used as a present-on-page
proof) plus IP-based rate limit (1 req / 60s, mongo-backed via
`RateLimiterMongo`).
- The `text` is wrapped with a "Message transmitted by the contact form of
…" prefix and sent both as plain-text and as
`textToSafeHtml(...)` → `htmlMsg`. The schema accepts text only, so the
caller never gets to inject HTML.

### Direct `sendMailI18n` (internal, no HTTP)

- File: `api/src/mails/service.ts:75-83`.
- Callers: `api/src/auth/router.ts`, `api/src/users/router.ts`,
`api/src/users/worker.ts`, `api/src/invitations/router.ts`,
`api/src/organizations/router.ts`.
- The i18n templates (`api/i18n/{en,fr,…}.js`, `mails:` section) are
authored alongside the service and contain HTML (`<a href="{origin}">`,
…). The substituted values are derived from a validated URL:
`sendMailI18n` asserts `params.link` is `http(s):` before deriving
`{host}`, `{origin}`, `{path}`.

## Known external callers

| Service | Endpoint | Caller (file:line) | What reaches the template |
|---------|----------|---------------------|---------------------------|
| portals | `POST /api/mails/contact` | `portal/app/components/page-element/basic/page-element-contact.vue:354` | `text` (the form body — schema rejects html, server escapes) |
| events | `POST /api/mails` | `events/api/src/notifications/service.ts:65` | `html` (notification `htmlBody`, third-party-supplied) — sanitized by `sanitizeMailHtml` |
| sd internal | `sendMailI18n` direct | auth / users / invitations / organizations routers, users worker | i18n template HTML; substituted params come from validated URLs and trusted config |

## Templates

- `api/src/mails/generic-mail.mjml` — used when `params.htmlButton` is set.
Placeholders inside `<mj-text>` (text context): `{htmlMsg}`,
`{htmlAlternativeLink}`, `{link}`, `{htmlCaption}`. Inside attributes
(URL context): `{logo}` (`src`), `{link}` (`href`),
`{theme.colors.primary}` (`background-color`).
- `api/src/mails/generic-mail-nobutton.mjml` — same shape without the
button block.
- `api/src/mails/{mail,mail-nobutton}.mjml` — optional operator-supplied
overrides for the main site (loaded from disk at startup).
- A legacy `/webapp/server/mails/mail.mjml` path is still read for back-
compat with a `console.error` to nag operators to migrate.

No template uses `<mj-include>`. Substitution happens *before* MJML parses,
so a value containing `<mj-include path="/etc/passwd"/>` would still be
honoured by mjml — that is why sanitization sits at the value boundary
above, not at the template.

## Operator-trusted inputs (bypass sanitization by design)

These values flow into the MJML pipeline unfiltered. They are part of the
same trust model as the rest of operator-supplied config:

- `config.mails.extraParams` (`api/src/mails/service.ts:109`) — spread last
into `tmplParams`, so an operator can override anything.
- `config.theme.*`, `site.theme.*` — colours and `logo` URL.
- `config.contact`, `site.mails.contact`, `config.mails.from`,
`site.mails.from` — addresses substituted as `{contact}` and used as
`From:` / `replyTo`.
- Operator-supplied templates `mail.mjml` / `mail-nobutton.mjml`.

This matches the broader "main-site / operator config is fully trusted"
posture in [`./email-trust-and-site-isolation.md`](./email-trust-and-site-isolation.md).

## Invariants

1. `POST /api/mails` requires both a valid `sendMails` secret **and**
`assertReqInternal(req)` — enforced by `assertReqInternalSecret`. The
internal-origin check is unconditional in production (no env-var bypass).
2. Caller-supplied `html` to `POST /api/mails` is run through
`sanitizeMailHtml` (a strict tag allow-list, `href` schemes restricted
to `http`/`https`/`mailto`) before reaching the MJML substitution.
3. Caller-supplied `text` to either endpoint reaches `htmlMsg` only via
`textToSafeHtml` (HTML-escape + `\n`→`<br>`).
4. The contact-form schema (`api/contract/contact-mail.ts`) admits `text`
only; the server never reads an html field from the contact-form caller.
5. `sendMailI18n` rejects a `params.link` whose protocol is not `http:` or
`https:`, so `{link}` (button `href`) and the derived `{origin}` /
`{host}` cannot carry a `javascript:` / `data:` payload into an i18n
template.
6. The MJML templates contain no `<mj-include>` and are not writable at
runtime; placeholder substitution is the only injection surface, and
it is gated by invariants 2–5.

Violations re-open the C-class injection paths flagged in the
2026-05 portals review around `microTemplate` + `mjml2html`.

## References

- `api/src/mails/router.ts` — both endpoints
- `api/src/mails/service.ts` — `sendMail`, `sendMailI18n`, MJML rendering
- `api/src/mails/escape.ts` — `textToSafeHtml`, `sanitizeMailHtml`
- `api/src/mails/generic-mail.mjml`, `generic-mail-nobutton.mjml`
- `api/types/mail/schema.js` — `/api/mails` body schema
- `api/contract/contact-mail.ts` — `/api/mails/contact` body schema
- `api/i18n/{en,fr,es,it,pt,de}.js` — i18n mail strings under `mails:`
- `tests/features/mails.api.spec.ts` — endpoint, sanitization, escape tests
Loading
Loading