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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH**

---

## [1.38.2] — 2026-07-10

### Security
- **Sanitiza `color_hex` na saída (anti CSS injection).** A cor do filamento é campo
de texto livre e ia direto para `style="…background:…"` e para o `stroke` do SVG.
O autoescape do Jinja impede sair do atributo, mas não impede injetar propriedades
CSS dentro dele. Novo filtro `hexcolor` só deixa passar um `#RGB`/`#RRGGBB` válido
(senão usa um neutro), aplicado em todas as amostras de cor e roscas (lista/detalhe
de rolos e filamentos, busca, fila de etiquetas, inventário). Baixa severidade
(só quem tem escrita define a cor; o CSP já bloqueia carregamento externo), mas é
a correção correta na camada de saída.

## [1.38.1] — 2026-07-10

### Changed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.38.1
1.38.2
14 changes: 14 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,20 @@ def localdate(value):
return dt.strftime(fmt)


_HEX_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")


@app.template_filter("hexcolor")
def hexcolor(value, default=""):
"""Sanitiza uma cor vinda do banco ANTES de injetá-la em `style=`/SVG. `color_hex`
é campo de texto livre no cadastro de filamento; sem isso, um valor tipo
`red;background:url(...)` faria CSS injection dentro do atributo (o autoescape do
Jinja barra a saída do atributo, mas não a injeção de propriedades). Só passa um
#RGB/#RRGGBB válido; qualquer outra coisa vira `default`."""
v = (value or "").strip()
return v if _HEX_COLOR_RE.match(v) else default


@app.route("/lang/<code>")
def set_lang(code):
if code in i18n.SUPPORTED:
Expand Down
2 changes: 1 addition & 1 deletion templates/filaments/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<svg width="{{ size }}" height="{{ size }}" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="15.9155" fill="none" class="donut-track" stroke-width="15"/>
<circle cx="25" cy="25" r="15.9155" fill="none"
stroke="{{ fill }}" stroke-width="15"
stroke="{{ fill | hexcolor('#6c757d') }}" stroke-width="15"
stroke-dasharray="{{ pct }} {{ 100 - pct }}"
transform="rotate(-90 25 25)"/>
</svg>
Expand Down
4 changes: 2 additions & 2 deletions templates/filaments/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<svg width="{{ size }}" height="{{ size }}" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="15.9155" fill="none" class="donut-track" stroke-width="15"/>
<circle cx="25" cy="25" r="15.9155" fill="none"
stroke="{{ fill }}" stroke-width="15"
stroke="{{ fill | hexcolor('#6c757d') }}" stroke-width="15"
stroke-dasharray="{{ pct }} {{ 100 - pct }}"
transform="rotate(-90 25 25)"/>
</svg>
Expand Down Expand Up @@ -67,7 +67,7 @@ <h4 class="mb-0 fw-bold">{{ _('Filamentos') }}</h4>
onclick="event.stopPropagation()">{{ f.material }}</a>
</td>
<td data-label="{{ _('Cor') }}">
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ f.color_hex or '#e0e0e0' }};border:1px solid #ccc;vertical-align:middle"></span>{{ f.color_display or '—' }}
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ f.color_hex | hexcolor('#e0e0e0') }};border:1px solid #ccc;vertical-align:middle"></span>{{ f.color_display or '—' }}
</td>
<td data-label="{{ _('Marca') }}">
{% if f.brand_logo %}
Expand Down
4 changes: 2 additions & 2 deletions templates/reports/inventory.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h4 class="mb-0 fw-bold">{{ _('Inventário') }} <span class="text-muted fs-6">({
data-material="{{ s.material }}"
data-family="{{ s.family }}"
data-hex="{{ s.color_hex }}"
data-fill="{{ fill }}"
data-fill="{{ fill | hexcolor('#6c757d') }}"
data-pct="{{ pct }}"
data-remaining="{% if s.current_net_g is not none %}{{ s.current_net_g|int }} g / {{ s.nominal_weight_g|int }} g ({{ pct|int }}%){% else %}{{ s.nominal_weight_g|int }} g · {{ _('não pesado') }}{% endif %}"
data-diameter="{{ s.diameter_mm }} mm"
Expand All @@ -34,7 +34,7 @@ <h4 class="mb-0 fw-bold">{{ _('Inventário') }} <span class="text-muted fs-6">({
data-link="{{ url_for('spools_detail', spool_id=s.id) }}">
<svg width="46" height="46" viewBox="0 0 50 50" class="d-block mx-auto">
<circle cx="25" cy="25" r="15.9155" fill="none" class="donut-track" stroke-width="15"/>
<circle cx="25" cy="25" r="15.9155" fill="none" stroke="{{ fill }}" stroke-width="15"
<circle cx="25" cy="25" r="15.9155" fill="none" stroke="{{ fill | hexcolor('#6c757d') }}" stroke-width="15"
stroke-dasharray="{{ pct }} {{ 100 - pct }}" transform="rotate(-90 25 25)" class="donut-arc"/>
</svg>
<div class="small fw-semibold text-truncate sc-inv-name">{{ s.brand }}</div>
Expand Down
2 changes: 1 addition & 1 deletion templates/reports/label_queue.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ <h4 class="mb-0 fw-bold"><i class="bi bi-printer me-2"></i>{{ _('Fila de Etiquet
<td data-label="{{ _('Material') }}"><span class="badge bg-secondary">{{ s.material }}</span></td>
<td data-label="{{ _('Marca / Família') }}">
{% if s.color_hex %}
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex }};border:1px solid #ccc;vertical-align:middle"></span>
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex | hexcolor('#e0e0e0') }};border:1px solid #ccc;vertical-align:middle"></span>
{% endif %}
{{ s.brand }} / {{ s.family }}
</td>
Expand Down
4 changes: 2 additions & 2 deletions templates/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ <h6 class="text-muted fw-semibold text-uppercase mb-2">{{ _('Spools') }} ({{ spo
<td data-label="{{ _('Material') }}"><span class="badge bg-secondary">{{ s.material }}</span></td>
<td data-label="{{ _('Marca / Família') }}">
{% if s.color_hex %}
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex }};border:1px solid #ccc;vertical-align:middle"></span>
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex | hexcolor('#e0e0e0') }};border:1px solid #ccc;vertical-align:middle"></span>
{% endif %}
{{ s.brand }} / {{ s.family }}
</td>
Expand Down Expand Up @@ -55,7 +55,7 @@ <h6 class="text-muted fw-semibold text-uppercase mb-2">{{ _('Filamentos') }} ({{
<td data-label="{{ _('Marca') }}">{{ f.brand }}</td>
<td data-label="{{ _('Família') }}">
{% if f.color_hex %}
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ f.color_hex }};border:1px solid #ccc;vertical-align:middle"></span>
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ f.color_hex | hexcolor('#e0e0e0') }};border:1px solid #ccc;vertical-align:middle"></span>
{% endif %}
{{ f.family }}
</td>
Expand Down
2 changes: 1 addition & 1 deletion templates/spools/_macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<svg width="34" height="34" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="15.9155" fill="none" class="donut-track" stroke-width="15"/>
<circle cx="25" cy="25" r="15.9155" fill="none"
stroke="{{ fill }}" stroke-width="15"
stroke="{{ fill | hexcolor('#6c757d') }}" stroke-width="15"
stroke-dasharray="{{ pct }} {{ 100 - pct }}"
transform="rotate(-90 25 25)"
class="donut-arc"/>
Expand Down
2 changes: 1 addition & 1 deletion templates/spools/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ <h4 class="mb-0 fw-bold ms-2">SP-{{ '%04d'|format(spool.id) }}</h4>
<div class="text-muted">{{ spool.family }}</div>
{% if spool.color_hex %}
<div class="text-muted small">
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ spool.color_hex }};border:1px solid #ccc;vertical-align:middle"></span>
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ spool.color_hex | hexcolor('#e0e0e0') }};border:1px solid #ccc;vertical-align:middle"></span>
<span class="font-monospace text-uppercase">{{ spool.color_hex }}</span>
</div>
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion templates/spools/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ <h4 class="mb-0 fw-bold">{% if spool %}{{ _('Editar') }} SP-{{ '%04d'|format(spo
<option value="">{{ _('Selecione um filamento...') }}</option>
{% for f in filaments %}
<option value="{{ f.id }}"
data-color="{{ f.color_hex or '' }}"
data-color="{{ f.color_hex | hexcolor('') }}"
data-color-name="{{ f.color_display or '' }}"
data-edit-url="{{ url_for('filaments_edit', filament_id=f.id, next=request.url) }}"
{% if selected_filament_id == f.id %}selected{% endif %}>
Expand Down
4 changes: 2 additions & 2 deletions templates/spools/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ <h4 class="mb-0 fw-bold">
data-name="SP-{{ '%04d'|format(s.id) }} {{ s.brand }} {{ s.material }}"
data-tare="{{ s.effective_tare_g|round(1) }}"
data-nominal="{{ s.nominal_weight_g|int }}"
data-fill="{{ fill }}"
data-fill="{{ fill | hexcolor('#6c757d') }}"
data-weigh-url="{{ url_for('spools_weigh', spool_id=s.id) }}">
<td class="text-center align-middle sc-stack-head" style="width:44px;padding:4px">
{{ donut(pct, fill, spool_id=s.id, tip=pct|int|string + _('% disponível')) }}
</td>
<td class="fw-bold sc-stack-head">SP-{{ '%04d'|format(s.id) }}</td>
<td data-label="{{ _('Material') }}"><span class="badge bg-secondary">{{ s.material }}</span></td>
<td data-label="{{ _('Cor') }}">
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex or '#e0e0e0' }};border:1px solid #ccc;vertical-align:middle"></span>{{ s.color_display or '—' }}
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex | hexcolor('#e0e0e0') }};border:1px solid #ccc;vertical-align:middle"></span>{{ s.color_display or '—' }}
</td>
<td data-label="{{ _('Marca / Família') }}">{{ s.brand }} / {{ s.family }}</td>
<td data-label="{{ _('Local') }}">{{ s.location or '—' }}</td>
Expand Down
29 changes: 29 additions & 0 deletions tests/test_security_color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""v1.38.2 — sanitização de color_hex na saída (anti CSS injection).

`color_hex` é campo de texto livre no cadastro de filamento e era injetado em
`style=`/SVG. O filtro `hexcolor` só deixa passar #RGB/#RRGGBB válido.
"""
_PAYLOAD = "#f00;background:url(https://evil.example/x)"


def test_hexcolor_filter_unit(app_module):
f = app_module.hexcolor
assert f("#fff") == "#fff"
assert f("#AabBcc") == "#AabBcc"
assert f("") == ""
assert f(_PAYLOAD) == "" # payload rejeitado
assert f(_PAYLOAD, "#e0e0e0") == "#e0e0e0" # cai no default
assert f("red") == "" # nome de cor não passa
assert f("#1234") == "" # 4 dígitos inválido


def test_malicious_color_not_rendered_in_lists(auth_client, db):
fid = db.create_filament(brand="Acme", material="PLA", family="PLA",
color_hex=_PAYLOAD, color_name="X")
db.create_spool(filament_id=fid, spool_model_id=None, custom_tare_g=200.0,
nominal_weight_g=1000.0, location="", purchase_date="",
purchase_price=None, notes="")
for path in ("/spools", "/filaments"):
html = auth_client.get(path).get_data(as_text=True)
assert "url(https://evil" not in html, path # injeção neutralizada
assert "background:#e0e0e0" in html, path # amostra usa o neutro