Accept an SVG logo: the format logos come in, and the sanitiser it needs #264
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
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#264
Loading…
Add table
Add a link
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?
Uploading a logo works today:
logo_uploadon the company form,logos.from_upload, andLogoSource.UPLOADalready recorded as where it came from. Transparency works too, and isnot merely preserved —
logos.processconverts toRGBA, fits the image withImageOps.containand pastes it onto a transparent 256×256 square, so a wordmark keeps itsshape and the padding is clear.
What is missing is the format logos actually come in.
logos.py's own docstring says so:This is that step.
Why it is worth doing
Two reasons, and the second is the weaker one.
file today has to convert it before Postulo will take it, and the message they get while
finding that out is wrong (see the papercut below).
size-6in a table that is invisible. On thecompany page, and on a high-DPI screen, a 256-pixel PNG of a wordmark is soft where the
vector would be sharp. Real, but small — this issue is mostly about the first reason.
The whole issue is the security work
An SVG is a document, not a picture. It can carry
<script>, event handler attributes(
onload=),<foreignObject>holding arbitrary HTML,xlink:hrefand<use href=…>pointing at other files, CSS
@import, and<image href="https://…">— that last one isprecisely what this module exists to prevent, since it would make every page view tell
somebody else's server which companies this person is looking at.
Rendered through
{% company_logo %}the file is an<img>, and a browser does not runscript in that context. The exposure is the direct visit.
jobs:company_logoserves thefile from our own origin through
serve_private_file, so an SVG fetched at that URL in atab is a same-origin document, and anything it carries runs as us.
So there are two paths, and they cost differently.
Path A — sanitise on the way in, store the SVG
Parse with
defusedxml, walk the tree against an allowlist of elements and attributes,drop everything else, re-serialise. Keep vectors, crisp at any size, no new rendering
dependency.
It needs, at minimum:
script, noforeignObject, no event handler attributes, no external references ofany kind (
href/xlink:hrefmay be#fragmentonly, never a URL);<style>and no@import, or a presentation-attributes-only policy;Content-Security-Policy: default-src 'none'; sandboxon the logo view, orContent-Disposition: attachmentfor SVG, so even asanitiser bug is not an origin-level one;
serve_private_file(..., download_name="logo.png")currently hardcodes the extension andwould need to vary with what is stored;
tests/security/cases per vector above, which is where the real confidence comes from.There is no well-maintained general-purpose SVG sanitiser in Python worth leaning on,
so this is an allowlist we write and own. That is a cost, and it should be stated plainly
rather than discovered.
Path B — accept SVG, rasterise, store PNG
Nothing changes about what is stored or served, the CSP is untouched, and no sanitiser
exists to be wrong. The person uploads the file they have and gets the transparent PNG the
pipeline already produces.
The cost is the rasteriser, and every option has a problem:
cairosvgpulls in system cairo — the same class of system-library dependency the[chromium]extra exists to escape, and it fails on Windows for the same reason;for drawing SVG inside a document, not for rasterising a standalone file — and on this
machine
weasyprint.svgwill not even import, for exactly thelibgobjectreason thatmotivated the Chromium fallback;
is an optional extra, so this would work on some installs and not others.
In other words, rasterising inherits the same two-backend split the PDF renderer already
carries, for a 256-pixel icon.
Recommendation
Path A. The sanitiser is a known quantity with a bounded allowlist and testable cases;
Path B's dependency cost is worse than it looks and would make logo support vary by install.
Path A is also what the module anticipated when it deferred this.
Ship it with the serving-view hardening in the same commit — the sanitiser and the response
headers are two halves of one defence, and neither should land alone.
A papercut to fix regardless of which path wins
The two ways of giving Postulo an SVG report it differently:
downloadchecks the content type and says the useful thing — "That is anSVG, which Postulo does not keep yet. A PNG or JPEG works." (
logos.py:127)logo_uploadis aforms.ImageField, so Pillow rejects it first and theperson is told "That file could not be read as an image."
The upload path is the one somebody with an SVG will actually use, and it gives the less
helpful of the two messages about a file that is perfectly valid. Worth correcting on its
own, before any of the above — it is a
clean_logo_uploadcheck for the SVG signature andthe message that already exists.
Note for whoever does:
forms.ImageFieldverifies with Pillow, so accepting SVG at allmeans moving to
FileFieldwith our own validation.Decided: Path A, and the 256×256 square goes
Two decisions, recorded so whoever picks this up does not re-open them.
Path A — sanitise and store the SVG. An allowlist over
defusedxml, no externalreferences of any kind, and the serving view hardened in the same commit.
Size is bounded by file size, not by dimensions.
LOGO_SIZE = 256goes, and with it thefit-onto-a-transparent-square step in
logos.process.What that means in practice
The square padding is not lost, it moves to CSS.
{% company_logo %}already rendersobject-containinside a square box at every call site —size-4on application cards androws,
size-6in the companies table,size-14on the company page. A non-square logoletterboxes in the browser exactly as it letterboxed in the file. Nothing in the layout
changes.
MAX_PIXELSstays. It looks like a size cap and is not one: it is the decompressionbomb guard, and it bounds what Pillow allocates while decoding, not what we keep. A
2 MB file can decode to hundreds of megabytes of RGBA, and that happens before any decision
about storage can be taken. It is a security control,
tests/security/should cover it, and"cap by file size" does not touch it.
An output budget is needed, because re-encoding does not preserve size. The bytes are
decoded and re-encoded rather than stored as they arrived — that is what drops metadata and
is not negotiable. But a re-encode can produce a larger file than it read, particularly a
photographic logo arriving as JPEG and leaving as PNG. So the rule has two numbers, not one:
MAX_BYTES, today 2 MB, andworth raising now that the output is no longer fixed at 256²);
until it fits.
That loop is what "cap by file size" actually means once the dimensions are free, and it
degrades gracefully — a small logo is kept exactly as given, a huge one is reduced only as
far as it has to be.
For SVG the same budget applies to the sanitised output, and it is the only sensible
control there: an SVG has no pixel dimensions to cap.
One measured note, which does not change the decision
Nothing currently displays a logo larger than
size-14— 56 CSS pixels, on the companypage. At 3× device pixel ratio that is 168 physical pixels, so the existing 256² raster was
already sufficient for every surface that exists today. The raster half of this change buys
nothing visible right now.
It is still the right call for the two reasons that are not about today: an SVG cannot be
capped by pixels at all, and storing a degraded copy of what somebody uploaded is a choice
that gets harder to reverse the longer it stands. Recorded so the absence of a visible
difference is not mistaken for the change having failed.
Existing logos will not change
Only the processed PNG is kept — the original bytes are gone — so every logo already stored
stays a 256×256 square for ever. Refresh re-fetches the ones with a
logo_source_urlandthey will come back at their own size; an uploaded one can only be replaced by uploading it
again. Not worth a migration, but worth saying in the changelog entry so nobody reports it
as a bug.
Correcting the framing above, and the number it implies
The previous comment measured the largest logo the interface draws today (
size-14, 56 CSSpixels) and concluded the raster half of this change "buys nothing visible right now". That
is true and it is the wrong test.
The reason for storing a logo at its own size is that the interface is not finished. A
company page with a proper header, a logo on an application record, a printed report or a
document theme that carries the employer's mark — none of those exist yet, and each of them
wants more than 56 pixels. Normalising to 256² is a decision that silently sets a ceiling on
every surface nobody has built, and it is irreversible in the worst way: the original bytes
are not kept, so the ceiling is discovered years later by a design that cannot have what it
needs.
Checked, for the record: no logo is rendered in any print or PDF surface today —
documents/themes/,resume/andapplications/report_print.htmlhave none. So this is aconstraint on future work rather than a present defect. That is precisely when it is cheap
to remove.
What it means for the output budget
If the point is not to restrict what can be built later, the budget has to be set for the
largest plausible render, not the largest current one. Two reference points:
or above.
So a long edge in the region of 1024 pixels covers every surface anybody is likely to
build, and a flat wordmark at that size is a PNG well under 200 KB. An output budget of
about 1 MB, with the input cap raised to around 5 MB, means the downscale-and-retry loop
described above effectively never engages: a normal logo is stored exactly as it was given,
and only a photographic or pathological file is reduced at all.
Which is the intent — the budget exists to stop one file being unreasonable, not to decide
in advance how large a logo is allowed to be.
Scope: plugin logos are in, and SVG is the preferred form for them
Two things this issue has to absorb.
1. Plugin logos already share this pipeline, silently
plugins/logos.py:143doesfrom postulo.jobs.logos import process as fitinside_render. The plugin logo pipeline is the company logo pipeline. So removing the 256²square changes every plugin's mark whether or not this issue mentions it — and it currently
does not. Any commit here touches both, and the tests should say so.
(The function-level import is there to dodge a cycle, which is one of the cases #248 counts.
Relevant below.)
2. Plugins may ship an SVG, and it is the better format for them
Decided: a plugin's logo should be allowed to be an SVG, and preferred as one.
The threat model is genuinely different from a user upload, and it is worth writing down why
rather than leaving it to be re-argued:
installed it has extended far more trust than an SVG could abuse. A hostile plugin does not
need a picture.
is what a mark is authored as. Forcing it through a raster round-trip degrades it for no
benefit — and at
size-6in a list, 24 CSS pixels, a crisp mark is the whole point.But it still goes through the sanitiser, for a reason that is about privacy rather than
script execution.
plugins/logos.pyexists precisely so that a vendor's server never learnswhich instances run their plugin — its own docstring says so. An SVG carrying
<image href="https://vendor.cdn/…">, a CSS@import, or a remote webfont reintroducesexactly that leak, and it would arrive by accident far more often than by malice: designers
put CDN references in SVGs as a matter of course.
So for plugin logos the sanitiser's job is no external references, ever. The script
stripping comes along for free and is worth keeping, but the external-reference rule is the
one that protects the promise this module was written to keep.
3. Where the sanitiser should live
It now has two consumers in two apps, and
plugins/logos.pyalready reaches intojobs/logos.pythrough a deferred import to avoid a cycle. Putting a shared sanitiser injobs/would deepen that;plugins/logos.pywould be importing an SVG policy out of thejob-search app, which is the wrong direction.
It should go somewhere neutral —
core/— withjobs/logos.pyandplugins/logos.pybothimporting it at module level. That removes one of the deferred imports #248 counts instead
of adding another, and it means the policy is stated once for every picture Postulo serves.
Consequences to carry through
png_for(plugin), theconnections:logoview and the{% plugin_logo %}tag all assumePNG in their names and in what they serve. Accepting SVG means a content type that varies,
the same problem
serve_private_file(..., download_name="logo.png")has on the companyside.
_cache: dict[str, bytes | None]inplugins/logos.pyholds bytes with no note ofwhat they are; it needs the type alongside.
Content-Security-Policy: default-src 'none'; sandboxon the response, or an attachment disposition — applies to theplugin logo view too, and for the same reason: a direct visit is a same-origin document.