Accept an SVG logo: the format logos come in, and the sanitiser it needs #264

Closed
opened 2026-09-17 18:26:03 +00:00 by tiagoagueda · 3 comments
Owner

Uploading a logo works today: logo_upload on the company form, logos.from_upload, and
LogoSource.UPLOAD already recorded as where it came from. Transparency works too, and is
not merely preserved — logos.process converts to RGBA, fits the image with
ImageOps.contain and pastes it onto a transparent 256×256 square, so a wordmark keeps its
shape and the padding is clear.

What is missing is the format logos actually come in. logos.py's own docstring says so:

raster only for now: PNG, JPEG, GIF and WebP. SVG is the format logos most often come
in and the one that needs care — it can carry scripts and references to other files, and
a direct visit to the file is not the <img> context where a browser refuses to run
them. Accepting SVG means a sanitiser, and that is its own step.

This is that step.

Why it is worth doing

Two reasons, and the second is the weaker one.

  • It is what people have. A company's press kit ships an SVG. Somebody with the right
    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).
  • Raster is resampled to 256×256. At size-6 in a table that is invisible. On the
    company 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:href and <use href=…>
pointing at other files, CSS @import, and <image href="https://…"> — that last one is
precisely 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 run
script in that context. The exposure is the direct visit. jobs:company_logo serves the
file from our own origin through serve_private_file, so an SVG fetched at that URL in a
tab 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:

  • an allowlist, not a blocklist — the shape of every sanitiser that has survived contact;
  • no script, no foreignObject, no event handler attributes, no external references of
    any kind (href/xlink:href may be #fragment only, never a URL);
  • no <style> and no @import, or a presentation-attributes-only policy;
  • the serving view hardened: a response Content-Security-Policy: default-src 'none'; sandbox on the logo view, or Content-Disposition: attachment for SVG, so even a
    sanitiser bug is not an origin-level one;
  • serve_private_file(..., download_name="logo.png") currently hardcodes the extension and
    would 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:

  • cairosvg pulls 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;
  • WeasyPrint is already a dependency and has an SVG renderer, but it is internal and meant
    for drawing SVG inside a document, not for rasterising a standalone file — and on this
    machine weasyprint.svg will not even import, for exactly the libgobject reason that
    motivated the Chromium fallback;
  • Playwright can screenshot an SVG at any size and is already the fallback renderer, but it
    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:

  • By URL, download checks the content type and says the useful thing — "That is an
    SVG, which Postulo does not keep yet. A PNG or JPEG works." (logos.py:127)
  • By upload, logo_upload is a forms.ImageField, so Pillow rejects it first and the
    person 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_upload check for the SVG signature and
the message that already exists.

Note for whoever does: forms.ImageField verifies with Pillow, so accepting SVG at all
means moving to FileField with our own validation.

Uploading a logo works today: `logo_upload` on the company form, `logos.from_upload`, and `LogoSource.UPLOAD` already recorded as where it came from. Transparency works too, and is not merely preserved — `logos.process` converts to `RGBA`, fits the image with `ImageOps.contain` and pastes it onto a transparent 256×256 square, so a wordmark keeps its shape and the padding is clear. What is missing is the format logos actually come in. `logos.py`'s own docstring says so: > **raster only** for now: PNG, JPEG, GIF and WebP. SVG is the format logos most often come > in and the one that needs care — it can carry scripts and references to other files, and > a direct visit to the file is not the `<img>` context where a browser refuses to run > them. Accepting SVG means a sanitiser, and that is its own step. This is that step. ## Why it is worth doing Two reasons, and the second is the weaker one. - **It is what people have.** A company's press kit ships an SVG. Somebody with the right 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). - **Raster is resampled to 256×256.** At `size-6` in a table that is invisible. On the company 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:href` and `<use href=…>` pointing at other files, CSS `@import`, and `<image href="https://…">` — that last one is precisely 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 run script in that context. **The exposure is the direct visit.** `jobs:company_logo` serves the file from our own origin through `serve_private_file`, so an SVG fetched at that URL in a tab 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: - an allowlist, not a blocklist — the shape of every sanitiser that has survived contact; - no `script`, no `foreignObject`, no event handler attributes, no external references of any kind (`href`/`xlink:href` may be `#fragment` only, never a URL); - no `<style>` and no `@import`, or a presentation-attributes-only policy; - the serving view hardened: a response `Content-Security-Policy: default-src 'none'; sandbox` on the logo view, or `Content-Disposition: attachment` for SVG, so even a sanitiser bug is not an origin-level one; - `serve_private_file(..., download_name="logo.png")` currently hardcodes the extension and would 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: - `cairosvg` pulls 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; - WeasyPrint is already a dependency and has an SVG renderer, but it is internal and meant for drawing SVG inside a document, not for rasterising a standalone file — and on this machine `weasyprint.svg` will not even import, for exactly the `libgobject` reason that motivated the Chromium fallback; - Playwright can screenshot an SVG at any size and is already the fallback renderer, but it 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: - **By URL**, `download` checks the content type and says the useful thing — "That is an SVG, which Postulo does not keep yet. A PNG or JPEG works." (`logos.py:127`) - **By upload**, `logo_upload` is a `forms.ImageField`, so Pillow rejects it first and the person 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_upload` check for the SVG signature and the message that already exists. Note for whoever does: `forms.ImageField` verifies with Pillow, so accepting SVG at all means moving to `FileField` with our own validation.
tiagoagueda added this to the 0.5.0 milestone 2026-09-17 18:26:03 +00:00
Author
Owner

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 external
references of any kind, and the serving view hardened in the same commit.

Size is bounded by file size, not by dimensions. LOGO_SIZE = 256 goes, and with it the
fit-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 renders
object-contain inside a square box at every call site — size-4 on application cards and
rows, size-6 in the companies table, size-14 on the company page. A non-square logo
letterboxes in the browser exactly as it letterboxed in the file. Nothing in the layout
changes.

MAX_PIXELS stays. It looks like a size cap and is not one: it is the decompression
bomb 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:

  • an input cap refusing the upload before it is decoded (MAX_BYTES, today 2 MB, and
    worth raising now that the output is no longer fixed at 256²);
  • an output budget: re-encode, and if the result exceeds it, downscale and try again
    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 company
page. 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_url and
they 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.

## 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 external references of any kind, and the serving view hardened in the same commit. **Size is bounded by file size, not by dimensions.** `LOGO_SIZE = 256` goes, and with it the fit-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 renders `object-contain` inside a square box at every call site — `size-4` on application cards and rows, `size-6` in the companies table, `size-14` on the company page. A non-square logo letterboxes in the browser exactly as it letterboxed in the file. Nothing in the layout changes. **`MAX_PIXELS` stays.** It looks like a size cap and is not one: it is the decompression bomb 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: - an **input cap** refusing the upload before it is decoded (`MAX_BYTES`, today 2 MB, and worth raising now that the output is no longer fixed at 256²); - an **output budget**: re-encode, and if the result exceeds it, downscale and try again 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 company page. 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_url` and they 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.
Author
Owner

Correcting the framing above, and the number it implies

The previous comment measured the largest logo the interface draws today (size-14, 56 CSS
pixels) 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/ and applications/report_print.html have none. So this is a
constraint 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:

  • a logo in a printed document at roughly 40 mm wide, at 300 dpi, is about 470 pixels;
  • a page header on a large screen at 2× device pixel ratio is comfortably in the same range
    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.

## Correcting the framing above, and the number it implies The previous comment measured the largest logo the interface draws today (`size-14`, 56 CSS pixels) 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/` and `applications/report_print.html` have none. So this is a constraint 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: - a logo in a printed document at roughly 40 mm wide, at 300 dpi, is about **470 pixels**; - a page header on a large screen at 2× device pixel ratio is comfortably in the same range 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.
Author
Owner

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:143 does from postulo.jobs.logos import process as fit inside
_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:

  • A plugin already runs arbitrary Python inside the Postulo process. An administrator who
    installed it has extended far more trust than an SVG could abuse. A hostile plugin does not
    need a picture.
  • A plugin's logo is a designed asset, drawn once by whoever wrote the plugin, and vector
    is what a mark is authored as. Forcing it through a raster round-trip degrades it for no
    benefit — and at size-6 in 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.py exists precisely so that a vendor's server never learns
which instances run their plugin — its own docstring says so. An SVG carrying
<image href="https://vendor.cdn/…">, a CSS @import, or a remote webfont reintroduces
exactly 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.py already reaches into
jobs/logos.py through a deferred import to avoid a cycle. Putting a shared sanitiser in
jobs/ would deepen that; plugins/logos.py would be importing an SVG policy out of the
job-search app, which is the wrong direction.

It should go somewhere neutral — core/ — with jobs/logos.py and plugins/logos.py both
importing 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), the connections:logo view and the {% plugin_logo %} tag all assume
    PNG 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 company
    side.
  • The _cache: dict[str, bytes | None] in plugins/logos.py holds bytes with no note of
    what they are; it needs the type alongside.
  • The serving hardening this issue already requires — Content-Security-Policy: default-src 'none'; sandbox on the response, or an attachment disposition — applies to the
    plugin logo view too, and for the same reason: a direct visit is a same-origin document.
## 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:143` does `from postulo.jobs.logos import process as fit` inside `_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: - **A plugin already runs arbitrary Python inside the Postulo process.** An administrator who installed it has extended far more trust than an SVG could abuse. A hostile plugin does not need a picture. - **A plugin's logo is a designed asset**, drawn once by whoever wrote the plugin, and vector is what a mark is authored as. Forcing it through a raster round-trip degrades it for no benefit — and at `size-6` in 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.py` exists precisely so that a vendor's server never learns which instances run their plugin — its own docstring says so. An SVG carrying `<image href="https://vendor.cdn/…">`, a CSS `@import`, or a remote webfont reintroduces exactly 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.py` already reaches into `jobs/logos.py` through a deferred import to avoid a cycle. Putting a shared sanitiser in `jobs/` would deepen that; `plugins/logos.py` would be importing an SVG policy out of the job-search app, which is the wrong direction. It should go somewhere neutral — `core/` — with `jobs/logos.py` and `plugins/logos.py` both importing 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)`, the `connections:logo` view and the `{% plugin_logo %}` tag all assume PNG 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 company side. - The `_cache: dict[str, bytes | None]` in `plugins/logos.py` holds bytes with no note of what they are; it needs the type alongside. - The serving hardening this issue already requires — `Content-Security-Policy: default-src 'none'; sandbox` on the response, or an attachment disposition — applies to the plugin logo view too, and for the same reason: a direct visit is a same-origin document.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#264
No description provided.