This document describes the security measures in the HA ESCPOS Thermal Printer integration and how to report vulnerabilities.
The integration implements input validation to prevent injection attacks and
resource exhaustion. Validation is centralized in
custom_components/escpos_printer/security.py and reused by every service
schema (Bronze quality-scale action-setup rule):
- Service-level schemas (voluptuous): every action registered via
hass.services.async_register(..., schema=...). REST / WebSocket / Python-script callers go through the same validation as the UI selectors. - Text input: length cap (
MAX_TEXT_LENGTH = 10000), control characters stripped. - Image URLs:
validate_image_urlrejects non-http(s)schemes, embedded credentials (https://user:pass@host/), IDN/punycode hostnames, and non-default ports.validate_image_url_and_resolveadditionally resolves the hostname and rejects private, loopback, link-local, reserved, multicast, and unspecified IPs (defends against SSRF to RFC1918 networks,127.0.0.1,::1, and cloud-metadata endpoints like169.254.169.254). HTTP redirects are followed manually and each redirect target is re-validated. A per-printer "Allow local image URLs" opt-in (default off) relaxes the private/loopback block and the port allowlist for that printer; the always-dangerous ranges remain blocked even when enabled: cloud-metadata (169.254.169.254, AWS IMDSv6fd00:ec2::254, and Alibaba Cloud100.100.100.200, the_ALWAYS_BLOCKED_HOSTSdenylist), link-local, multicast, reserved, and unspecified. Enabling it turns that printer'sprint_image_urlinto an unauthenticated LAN-reach primitive (the service has no per-user authorization), so enable it only where the callers are trusted. - Local image paths:
Path.resolve(strict=True)dereferences symlinks before the extension / size / allowlist checks; the finalopen()usesO_NOFOLLOWto defeat TOCTOU swaps. Paths outsideallowlist_external_dirsare rejected (no warn-but-read). - Camera / image entity sources: the calling user's permissions are
checked via
user.permissions.check_entity(entity_id, POLICY_READ); denied users receiveUnauthorized(HTTP 403 from the WebSocket / REST API). Internal calls without auser_idand admins bypass. - Base64 data URIs: input length capped before regex/decoding
(no OOM on a 200 MB base64 string); subtype pinned to
png|jpe?g|gif|bmp|tiff|webp(no SVG / XML decoder reach). - Pillow: a decompression-bomb guard is enforced per-decode against
the image header dimensions (before any full-bitmap allocation),
scoped to this integration so Pillow's process-global limit is left
untouched for other Home Assistant consumers;
Image.openis invoked with a pinnedformats=allow-list. - Numeric input: every numeric parameter validated within safe
bounds (
MAX_FEED_LINES,IMAGE_FRAGMENT_MIN/MAX, etc.) declared insecurity.pyand reused by the voluptuous schemas inservices/schemas.py.
The project includes automated security scanning as part of the development and CI/CD process:
- Bandit: Performs static analysis to detect common security issues in Python code
- pip-audit: Audits installed packages for known vulnerabilities
- Ruff Security Rules (
Scategory): Security-focused linting rules run as part of the main lint job
# Example of secure input validation
from .security import validate_text_input, validate_numeric_input
text = validate_text_input(user_input)
feed_lines = validate_numeric_input(feed_param, 0, MAX_FEED_LINES, "feed lines")# Example of secure logging that prevents information disclosure
from .security import sanitize_log_message
log_msg = sanitize_log_message(
f"Processing data: {data}",
["password", "token", "key"]
)
_LOGGER.debug(log_msg)sanitize_log_message redacts:
field=valuepairs whose field name appears in the default sensitive list (password,token,key,secret,data,text,address,mac,alias,url,path,host,image,source).- URL userinfo (
scheme://user:pass@host/...→scheme://[REDACTED]@host/...). - Filesystem paths under HA's standard mount points (
/config/,/media/,/share/,/ssl/,/addon_configs/,/data/): preserves the prefix, redacts the rest. - Bluetooth MAC addresses (preserves the 3-octet OUI for vendor lookups, redacts the device-specific portion as personal data under GDPR).
- Maximum text length: 10,000 characters
- Maximum QR data length: 2,000 characters
- Maximum barcode data length: 100 characters
- Maximum image download size: 10 MB (also the decoded cap for base64 data URIs)
- Maximum decoded pixel count: 20 million per decode (40 million with
auto_resize, since the source is downscaled after decode) - Maximum processed image height: 8192 px
- Maximum image slices per print: 64 (avoids paper-DoS via tall ribbons)
- Maximum feed lines: 50
- Maximum beep repetitions: 9 (the cap python-escpos itself enforces)
This section documents the current security posture (post-Phase 2 hardening, 0.7.0+). Items previously listed under "Known Limitations" have moved to "Mitigated" with the implementation pointer.
- DNS rebinding for HTTP image fetch: The URL validator resolves
the hostname and returns the address set; the fetcher then builds a
per-request
aiohttpsession with_StaticResolver(image_sources._StaticResolver) pinned to those addresses. A 0-TTL hostile DNS server cannot swap public → private between validation and connect. Each redirect hop runs through the validator again and gets a fresh pin. CWE-918 / CWE-350. - TOCTOU symlink swap on local-file reads:
Path.resolve()dereferences symlinks during validation; the file is then opened withO_NOFOLLOW(security.open_local_image_no_follow,open_local_font_no_follow) so a symlink swap between stat and open is also defeated. CWE-59 / CWE-367. - TOCTOU symlink swap on preview writes: Preview-service file
writes use
O_NOFOLLOW | O_TRUNC | 0o600viasecurity.write_file_no_follow; an attacker who plants a symlink under tempdir between path-validation and image-save cannot redirect the write into an arbitrary file. CWE-59 / CWE-367. - Preview
output_pathprivilege escalation:preview_image,preview_box,preview_tablenow restrict user-suppliedoutput_pathto the system tempdir. Previously a non-admin HA user could callpreview_imagewithoutput_path: /config/configuration.yamland clobber it with rendered PNG bytes. CWE-862 / CWE-552. - IDN homograph bypass:
validate_image_urlnow IDNA-encodes raw-Unicode hostnames before thexn--substring check, so both例え.テストandxn--r8jz45g.xn--zckzahare rejected. CWE-918. - Exception-message information disclosure:
services/_handler_utils.py_for_each_targetroutes every service handler's exception throughsanitize_log_messageso USB serials, BT MACs, and filesystem paths from pyusb/pyserial/python-escpos do not leak into the HA Frontend toast. CWE-209 / CWE-532. - Font path narrowed trust:
security.validate_font_path_with_fonts_diraccepts paths under HA'sallowlist_external_dirsOR under<config>/fonts/(auto-created on integration setup). The<config>/fonts/widening is the only narrowing; all other path-based services use the standard allowlist. - Cancel-during-cleanup paper hang:
print_text_with_imagewraps its cleanup_apply_cut_and_feedinasyncio.shieldso a second cancellation mid-flush cannot leave paper attached.
- Trust boundary: any HA user who can call
escpos_printer.print_imageornotify.<printer>can print to your physical paper roll. Restrict service exposure via HA's standard scripts / scenes / Lovelace card permissions for shared installations. - ESC/POS protocol bytes in printed content:
validate_text_inputstrips C0 control characters but not all printable ESC/POS escape sequences. If a downstream POS scanner consumes the receipt as data (rather than a human reading paper), additional sanitization may be warranted. - Camera / image entity authorization:
_check_user_can_read_entityforwardsServiceCall.contextthrough every image source resolver so a non-admin user invokingprint_camera_snapshot/print_image_entityis blocked from cameras / image entities they cannot read. HA admins bypass entity permissions by design; admin users can print any camera regardless. - Blueprint template safety: the
variables:block of every shipped blueprint is rendered through HA's sandboxed Jinja environment. Tests intests/test_blueprints_template_safety.pypin this and include a regression canary asserting that an unsafelist.append()template raises. Coverage isvariables:-only;data:blocks are not sandbox-rendered (this matches HA core behaviour).
Bandit runs in CI as bandit -r custom_components/escpos_printer -lll. The
-lll flag fails the build only on HIGH-severity findings; LOW/MEDIUM
findings appear in the JSON report uploaded as SARIF. Project-specific
ignores live in pyproject.toml under [tool.ruff.lint] ignore for the
flake8-bandit (S) rule family.
[tool.ruff.lint]
select = [
"S", # flake8-bandit (security)
# ... other rules
]The S ruleset runs as part of the main ruff check . invocation in
validate.yml; there is no separate security-only ruff pass.
The project includes automated security scanning in CI/CD:
- Dependency Scanning: Runs on every push and pull request
- Code Security Analysis: Static analysis for security vulnerabilities
- Automated Reporting: Security findings are reported and can block merges
- Scheduled Scans: Weekly security scans
# Python security linting (HIGH-severity gate)
bandit -r custom_components/escpos_printer -lll
# Dependency audit
pip-auditIf you discover a security vulnerability in this integration:
- Do not create a public GitHub issue.
- Report it privately via GitHub Security Advisories.
- Include detailed information about the vulnerability and a reproduction case.
- Allow reasonable time for response and a fix before public disclosure.