From 5eb7cd27445eec99648d49a8523c5004f68e3cb8 Mon Sep 17 00:00:00 2001 From: Alexandre <44178713+alexbelgium@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:13:31 +0200 Subject: [PATCH] fix(seerr): re-encode ingress query strings for the OpenAPI validator (#2917) * fix(seerr): re-encode ingress query strings for the OpenAPI validator Searches through ingress fail with a 400 from the Seerr API, which the UI reports as "500 Internal Server Error". The same searches succeed on the directly published port 5055. Supervisor proxies ingress traffic with `params=request.query` (supervisor/api/ingress.py) - an already-decoded MultiDict - so aiohttp/yarl re-encodes the query string on the way to the add-on. yarl's safe set is far wider than the one express-openapi-validator accepts: it emits a space as "+" and forwards ":", "/", "@", "!", "$", "'", "(", ")", "*" and "," bare, while the validator tests the raw, still-encoded value against RESERVED_CHARS = /[\:\/\?#\[\]@!\$&\'()\*\+,;=]/ and rejects the request. That breaks most real titles - "Monsters, Inc.", "Ocean's Eleven", "Mission: Impossible", "Mamma Mia!". An njs handler now re-encodes exactly those characters before proxying. This is lossless: yarl only emits them bare when they were literal characters of the value, since anything ambiguous arrives already encoded ("+" as %2B, "&" as %26, "=" as %3D). "&" and "=" are left alone as the query string's own separators, and the path is forwarded byte-for-byte. Verified against a real express-openapi-validator over all 19 characters in RESERVED_CHARS, and diffed byte-for-byte against the previous config across representative traffic: only query-string encoding changes. Fixes #2906 Fixes #2646 * fix(seerr): ship the njs load_module snippet and encode "?" and ";" Addresses two review findings. 1. The njs module never loaded. .templates/ha_automatic_packages.sh moves the rootfs /etc/nginx aside to /etc/nginx2 before installing nginx, then does `rm -r /etc/nginx` and restores the saved tree. That deletes the load_module snippet nginx-mod-http-js installs, so nginx aborted with `unknown directive "js_import"` and the service's finish hook would have shut the add-on down - all ingress dead, not just search. The image build still passed CI because it never starts nginx. The snippet now ships in the rootfs so it survives the swap, under the package's own filename so the two can never both be present and double-load the module. 2. "?" arrives bare from yarl and was not encoded. It slipped through testing because the validator strips one occurrence with `qs.replace('?', '')` before checking, so a single "?" passes by accident and only a second one ("Who? What?") returned 400. NEEDS_ENCODING is now derived from the validator's RESERVED_CHARS minus the "&" and "=" separators, rather than from the characters yarl happens to emit bare today, so it stays correct if either side changes its safe set. The added characters are a no-op for current traffic: yarl already percent-encodes "# [ ] ;", so Seerr receives them encoded regardless. Verified by replaying the build-time /etc/nginx2 swap and starting nginx, which fails without the snippet and serves correctly with it; by sending every RESERVED_CHAR bare; and by diffing forwarded bytes against master, unchanged at 2/20 with all query-parser edge cases identical. * style(seerr): satisfy Codacy - double quotes in njs, changelog blank lines Clears the 11 new Info-level Codacy findings: 9x ESLint 'quotes' in njs/ingress.js and 2x markdownlint MD022/MD032 on the changelog entry. No behaviour change; re-verified through the build-time /etc/nginx2 swap. --- seerr/CHANGELOG.md | 4 + seerr/Dockerfile | 2 +- seerr/config.yaml | 2 +- .../rootfs/etc/nginx/modules/10_http_js.conf | 14 ++++ seerr/rootfs/etc/nginx/nginx.conf | 5 ++ seerr/rootfs/etc/nginx/njs/ingress.js | 83 +++++++++++++++++++ seerr/rootfs/etc/nginx/servers/ingress.conf | 8 +- 7 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 seerr/rootfs/etc/nginx/modules/10_http_js.conf create mode 100644 seerr/rootfs/etc/nginx/njs/ingress.js diff --git a/seerr/CHANGELOG.md b/seerr/CHANGELOG.md index 79825e2987..81aae672d5 100644 --- a/seerr/CHANGELOG.md +++ b/seerr/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.3.0.1 (2026-07-28) + +- Fixed searches failing through ingress with `500 Internal Server Error` (#2906, #2646). Home Assistant's ingress proxy re-encodes the query string and passes a space as `+`, along with a bare `:` `/` `?` `@` `!` `$` `'` `(` `)` `*` `,` - all of which Seerr's OpenAPI validator rejects as reserved characters. Nginx now re-encodes them before proxying, so titles such as `Monsters, Inc.`, `Ocean's Eleven`, `Mission: Impossible` and `Who? What?` search correctly. Only ingress was affected; the directly published port 5055 always worked. + ## 3.3.0 (2026-06-05) - Update to latest version from seerr-team/seerr (changelog : https://github.com/seerr-team/seerr/releases) diff --git a/seerr/Dockerfile b/seerr/Dockerfile index d098b78e3d..859ac2cb64 100644 --- a/seerr/Dockerfile +++ b/seerr/Dockerfile @@ -56,7 +56,7 @@ COPY ha_automodules.sh /ha_automodules.sh RUN chmod 744 /ha_automodules.sh && /ha_automodules.sh "$MODULES" && rm /ha_automodules.sh # Manual apps -ENV PACKAGES="nginx" +ENV PACKAGES="nginx nginx-mod-http-js" # Automatic apps & bashio COPY ha_autoapps.sh /ha_autoapps.sh diff --git a/seerr/config.yaml b/seerr/config.yaml index 2299bd1e5c..b41b8401d0 100644 --- a/seerr/config.yaml +++ b/seerr/config.yaml @@ -96,4 +96,4 @@ schema: slug: seerr udev: true url: https://github.com/alexbelgium/hassio-addons/tree/master/seerr -version: "3.3.0" +version: "3.3.0.1" diff --git a/seerr/rootfs/etc/nginx/modules/10_http_js.conf b/seerr/rootfs/etc/nginx/modules/10_http_js.conf new file mode 100644 index 0000000000..1e77d2067c --- /dev/null +++ b/seerr/rootfs/etc/nginx/modules/10_http_js.conf @@ -0,0 +1,14 @@ +# Load the njs module used by njs/ingress.js. +# +# The nginx-mod-http-js package ships this same file, but it cannot be relied +# on: .templates/ha_automatic_packages.sh moves the rootfs /etc/nginx aside to +# /etc/nginx2 before installing nginx, then does `rm -r /etc/nginx` and restores +# the saved tree afterwards. That deletes anything the package placed under +# /etc/nginx, including its own load_module snippet, and nginx would then fail +# to start with "unknown directive js_import". +# +# Shipping it in the rootfs makes it survive that swap. The filename matches the +# package's on purpose: if the package's copy is ever kept instead, it overwrites +# this one rather than adding a second load_module for the same module, which +# nginx rejects as a duplicate. +load_module /usr/lib/nginx/modules/ngx_http_js_module.so; diff --git a/seerr/rootfs/etc/nginx/nginx.conf b/seerr/rootfs/etc/nginx/nginx.conf index 7e5bc6f7cb..1d703a701a 100644 --- a/seerr/rootfs/etc/nginx/nginx.conf +++ b/seerr/rootfs/etc/nginx/nginx.conf @@ -49,6 +49,11 @@ http { '' close; } + # Repair the query string mangled by Supervisor's ingress proxy before it + # reaches Seerr's OpenAPI validator. See njs/ingress.js for the full story. + js_import ingress from /etc/nginx/njs/ingress.js; + js_set $ingress_uri ingress.uri; + include /etc/nginx/includes/resolver.conf; include /etc/nginx/includes/upstream.conf; diff --git a/seerr/rootfs/etc/nginx/njs/ingress.js b/seerr/rootfs/etc/nginx/njs/ingress.js new file mode 100644 index 0000000000..70e5aefc51 --- /dev/null +++ b/seerr/rootfs/etc/nginx/njs/ingress.js @@ -0,0 +1,83 @@ +/* + * Repair the query string of a Home Assistant ingress request. + * + * Supervisor proxies ingress traffic with `params=request.query` + * (supervisor/api/ingress.py), so aiohttp/yarl re-encodes an already-decoded + * query string on the way to this add-on. yarl's "safe" set is much wider than + * the one Seerr's express-openapi-validator will accept: yarl emits a space as + * "+" and passes ":", "/", "?", "@", "!", "$", "'", "(", ")", "*" and "," + * through bare, while the validator checks the raw, still-encoded value against + * + * RESERVED_CHARS = /[\:\/\?#\[\]@!\$&\'()\*\+,;=]/ + * + * and answers 400 "Parameter '' must be url encoded". Every search for a + * title containing a space or punctuation therefore fails - "Monsters, Inc.", + * "Ocean's Eleven", "Mission: Impossible" - which Seerr's UI reports as a + * 500. The same requests succeed on the directly published port 5055, which + * does not pass through Supervisor. + * + * "?" deserves a note: the validator strips one with `qs.replace('?', '')` + * before testing, so a single bare "?" slips through by accident and only a + * second one ("Who? What?") produces the 400. It is encoded here regardless. + * + * Re-encoding those characters here is lossless, because yarl only ever emits + * them bare when they were literal characters of the value: anything the user + * actually typed that is ambiguous comes through already percent-encoded + * (a typed "+" arrives as "%2B", "&" as "%26", "=" as "%3D"). + * + * "&" and "=" are deliberately NOT re-encoded: they are the query string's own + * separators, so a bare one is always structural. + */ + +/* + * Every character of the validator's RESERVED_CHARS except "&" and "=", which + * are the query string's own separators and are handled above. Deriving the + * set from what the validator rejects - rather than from what yarl currently + * emits bare - keeps this correct if either side changes its safe set. + */ +var NEEDS_ENCODING = /[:\/?#\[\]@!$'()*,;]/g; + +function encodePart(part) { + return part + /* yarl encodes a space as "+"; a literal "+" arrives as "%2B". */ + .replace(/\+/g, "%20") + .replace(NEEDS_ENCODING, function (c) { + return "%" + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +/* + * Returns the request URI with the path untouched byte-for-byte and only the + * query string repaired. Used as the proxy_pass target. + */ +function uri(r) { + var raw = r.variables.request_uri; + var split = raw.indexOf("?"); + + if (split < 0) { + return raw; + } + + var path = raw.substring(0, split); + var args = raw.substring(split + 1); + + /* A bare trailing "?" is forwarded as-is, so the URI stays byte-for-byte. */ + if (args === "") { + return raw; + } + + var repaired = args + .split("&") + .map(function (pair) { + var eq = pair.indexOf("="); + if (eq < 0) { + return encodePart(pair); + } + return encodePart(pair.substring(0, eq)) + "=" + encodePart(pair.substring(eq + 1)); + }) + .join("&"); + + return path + "?" + repaired; +} + +export default { uri }; diff --git a/seerr/rootfs/etc/nginx/servers/ingress.conf b/seerr/rootfs/etc/nginx/servers/ingress.conf index 91ebeb1826..4723e8934a 100644 --- a/seerr/rootfs/etc/nginx/servers/ingress.conf +++ b/seerr/rootfs/etc/nginx/servers/ingress.conf @@ -10,9 +10,11 @@ server { location ^~ / { set $app '%%ingress_entry%%'; - # Forward the raw request URI exactly as received by this nginx. - # This is the safest way to preserve query-string encoding. - proxy_pass http://127.0.0.1:5055$request_uri; + # Forward the request URI with the path byte-for-byte as received, and + # the query string re-encoded so the characters Supervisor's ingress + # proxy passes through bare (a space as "+", plus ":/@!$'()*,") do not + # trip Seerr's OpenAPI validator. See njs/ingress.js for the details. + proxy_pass http://127.0.0.1:5055$ingress_uri; proxy_http_version 1.1; proxy_set_header Referer $http_referer;