Basic Markdown in a listing's details, rendered on the server and sanitised #665
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#665
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
From the list of 2026-10-02: "Listing details can support basic MD syntax." A listing's description is plain text today; a person who types or edits one cannot make a list, a link or a heading of it. The page must keep working with scripts off and under the strict content security policy, so the rendering is the server's.
What exists today
JobPosting.descriptionis aTextField(src/postulo/jobs/models.py:898). The form's help text promises it "is kept exactly as pasted" (jobs/forms.py:71-74); the field is a ten-rowTextarea(jobs/forms.py:715-720), and the capture review form edits the same value (templates/jobs/capture_review.html:150,jobs/capture_views.py:250).whitespace-pre-line(templates/jobs/posting_detail.html:61-62); the Listings table prints it clamped to two lines (templates/jobs/partials/listing_row.html:80-84); search takes an excerpt from it (core/search.py:165-178); the API returns it on the detail only (api/schemas.py:1012); the archive carries it asdescription(core/export.py:208,POSTING_FIELDS); CSV import appends notes to it (core/csv_import.py:991).plugins/base.py:30,:91-92). It is text read off somebody else's page, not Markdown, so a line that begins with-,*,#or a number is, in the reader's mind, a line and not markup.src/postuloparses it, anduv.lockholds none ofmarkdown-it-py,mistune,nh3,bleachormarkdown. #480 (open) lists Markdown as a candidate export format, which is the other direction.SECURE_CSPallows scripts and styles only from'self'(plus a nonce for style on one kind of page, the CV and letter previews) and images from'self'anddata:(config/settings/base.py:671-698). Nothing here may need script, and a rendered image must never reach another host. The project already treats untrusted markup as two halves of one defence (core/pictures.pyfor SVG,core/files.py:51-62).applications/ical.py:1-12anddocuments/docx.py:1-12write a format out from structured data in a page of code, to avoid a dependency that would "bring the rest of the standard along" or a C extension;docs/PLAN.md:132-136kept BeautifulSoup and lxml out for the same reason. Both are writers of a fixed shape. This is a parser of arbitrary input whose output is HTML, and so the precedent points the other way here.Checked against
mainatf09b6010b.Proposal
JobPosting.description_formatwith choices Plain text and Markdown, default Plain text (a migration with no data change). Existing and captured text stays plain; a person who wants Markdown on one listing chooses it on the listing form and on the capture review form, whose help text says what is supported.core/markdown.py, so a later field can use it):render(text) -> SafeStringandplain(text) -> str. Parser: markdown-it-py with thezeropreset,htmloff,linkifyoff,breakson, and exactly these rules enabled: paragraphs, line breaks, emphasis and strong emphasis, inline code, ordered and unordered lists, block quotes, links (inline, reference and<https://…>), headings. Not enabled: raw HTML, images, tables, task lists, footnotes, fenced code, autolinking of bare addresses. Headings are shifted down so they cannot compete with the page's own:#renders as<h3>, and nothing above<h6>.nh3):p br strong em code ul ol li blockquote h3 h4 h5 h6 a;hrefonaonly, with the schemeshttp,httpsandmailto;rel="noopener noreferrer external"andtarget="_blank"forced, as the page's existing "Original posting" link has (posting_detail.html:146). The CSP is the third layer and is not relied on.assets/css/app.csswith the same tokens as the rest (list markers, link underline in the brand colour, heading sizes, quote border), thennpm run build:cssand the committed stylesheet. Plain text keepswhitespace-pre-line.plain(text)(the same parse, rendered as text, so**and[x](y)markers do not show). The API returns the stored source and a newdescription_format; it does not return HTML (a client would have to sanitise it again). The archive carriesdescription_format.jobs/forms.py:71) says what Markdown is supported and that the text is stored as typed; the wiki's Tracking applications gets a short section.Decisions
*,#or1)at the start of a line as markup changes what hundreds of existing listings say. The cost is a second field on the form.img-srcincludesdata:, and a remote image would tell the host who looked; the project's rule is that a page makes no request on a reader's behalf (accounts/avatars.py:1-10).nh3over the output. Recommended: both. With raw HTML off the second pass should never change anything, and that is the reason it is worth having: it holds if somebody later turnshtmlon or adds a plugin, as the SVG allowlist and the file policy do for pictures. Conflict to record:docs/PLAN.md:132-136kept C extensions out of the core, andnh3is a compiled (Rust) wheel. The core already depends on compiled wheels (Pillow, cryptography, psycopg-binary, brotli, zopfli, inuv.lock), so the argument that was made about lxml in M4 has not been applied since; if the maintainer would rather not add one, the fallback is the parser alone with the tests below.Prior art
Versions and advisories checked on 2026-10-02 against PyPI and OSV (
api.osv.dev). OSV lists advisories over all versions; the second number is those that affect the latest release.markdown-it-py(https://github.com/executablebooks/markdown-it-py): a CommonMark parser, a port of markdown-it. MIT. 4.2.0, released 2026-05-07. Pure Python, requires Python 3.10 or later, one dependency (mdurl, MIT, 0.1.2 from 2022-08-14, pure Python). 4 entries on record; the two GitHub advisories are denial-of-service issues fixed in 2.2.0 (2023); 0 affect 4.2.0. Its documentation says the defaultcommonmarkpreset enables HTML and is "less secure", that thejs-defaultpreset is recommended for untrusted input, and that it blocksjavascript:,vbscript:,file:anddata:links (with limited image exceptions). Not a dependency today. Adopt, configured as in part 2 (not from a preset that enables HTML).mistune(https://github.com/lepture/mistune): BSD-3-Clause; 3.3.4, released 2026-07-22; pure Python. 40 advisories on record, among them XSS through percent-encodedjavascript:addresses insafe_url()(published 2026-07-20, fixed in 3.3.0) and several directive and denial-of-service issues; 0 affect 3.3.4. A record of cross-site scripting in its URL handling that recent is the reason to prefer the other parser. Not adopted.markdown) (BSD-3-Clause; 3.11, released 2026-09-25; 2 advisories, fixed in 3.8.1): passes raw HTML through by default (not re-checked here against its current documentation), so it would depend on the sanitiser for everything. Not adopted.cmarkgfm(MIT; 2025.10.22; C extension over cmark-gfm; 0 advisories) andcommonmark(BSD-3-Clause; 0.9.2; 0 advisories): the first is a compiled dependency with nothing the parser above lacks; the second's maintenance state is not verified. Not adopted.nh3(https://github.com/messense/nh3): Python bindings for the Rust crateammonia. MIT (the Rust crateammonia: MIT or Apache-2.0). 0.3.7, released 2026-08-23; 0 advisories on OSV for the package (RustSec forammoniaitself was not checked: not verified). Wheels exist for manylinux on x86_64 and aarch64 (abi3, Python 3.8 and later), which is what the Debian-based image installs, so it needs no compiler. No runtime dependencies. Not a dependency today. Adopt as the second layer, subject to the decision above.bleach: 6.4.0, released 2026-06-05. Its README now says that Bleach "is no longer maintained" with "no future releases including for security issues", and the repository was archived on 2026-06-10. 13 advisories on record, 0 affect 6.4.0. Rejected: nothing will fix the next one.markdown2: MIT; 2.5.5; 8 advisories on record, several of them cross-site scripting. Not adopted.ical.pyanddocx.pydo for their formats. The rule the maintainer set is to look for a library first; the library is there, maintained and clean, and a hand-written Markdown-to-HTML path is exactly where cross-site scripting is introduced. Rejected.Builds on
#480 (Markdown as an export format, the other direction), #264 (an SVG sanitiser and a hardened file policy as two halves of one defence).
Needed by
Nothing.
Done when
tests/test_markdown.py: each rule of the subset renders as specified; raw HTML, a<script>element, anonerror=attribute, animage,[x](javascript:…), adata:link and a percent-encoded or entity-encoded scheme come out as text or as nothing; everyahas the forcedrelandtarget;plain()strips the markers; the markdown-it configuration itself is asserted (htmloff, no image rule, no table rule), so a change to it fails a test.tests/security/test_markdown.pyrenders the 40,000-character worst cases (long runs of**x**,[[[[,>>>>, deeply nested lists, a very long link title) and asserts a bounded time and an output that still passes the allowlist.tests/test_listings.py: a listing in Markdown shows a list and a link on its page and plain text in the table, the search excerpt and the API; one in plain text is unchanged, and so is an existing listing after the migration.tests/e2e/test_accessibility.py,tests/e2e/) visits a Markdown listing under the real policy with no violation, axe-clean in both themes, with scripts off as well;tests/test_stylesheet.pypasses with the rebuilt CSS andtests/test_template_lint.pywith logical utilities only.core/export.pyFORMAT_VERSION, 29 onf09b6010b; one more than whatever it is when the commit lands) fordescription_format, and an older archive imports as plain; the candidate format (4) is unchanged. API:description_formaton the listing detail and on writes (tests/test_api.py). Processing record (core/gdpr.py) unchanged. Search needs no change beyond the excerpt. CV print choice (#308) does not apply.tests/test_wiki_surface.pydoes not apply (no new plugin-surface name).pyproject.tomlanduv.lockgainmarkdown-it-py(withmdurl) andnh3;THIRD-PARTY.mdlists only works committed to the tree and says dependencies arrive under their own licences, so it needs no row, and the image's bill of materials carries them; strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and Weblate (#349).From the list of improvements of 2026-10-02.
Done in
9e5d2ad62(on main; CI green at15e6d7ee4). A listing's description can be set to Markdown per listing, rendered on the server and sanitised (no images, raw HTML or relative links). Wiki: Listings page.