Injicér JavaScript på alle dine sider med NPMplus og sub_filter
Jeg ville have Matomo-analytics på tværs af alle mine selvhostede apps uden at
skulle ind og redigere hver enkelt app’s HTML. Nogle af dem kan jeg slet ikke rette
i.. de er tredjeparts-images. Løsningen sidder ét sted: i min reverse proxy.
NPMplus bygger på OpenResty, så jeg har både sub_filter og Lua til
rådighed, og dermed kan jeg injicere et <script> ind i ethvert HTML-svar, der
passerer igennem proxyen.
Her er hele opskriften, inklusive de to fælder der koster tid: gzip og CSP.
Den korte version
Kernen er to ting: find </head> i svaret og sæt mit script ind lige før det.
# Slå upstream-komprimering fra, ellers ser sub_filter kun gzippede bytes
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<script src="/_inject/injector.js" defer></script></head>';
sub_filter_once on;
sub_filter_types text/html;
I NPMplus lægger du det enten på den enkelte proxy host under Advanced > Custom
Nginx Configuration, eller globalt i custom_nginx/server_proxy.conf, så det
gælder alle hosts på én gang. Det sidste er det jeg gør.
Fælde 1: gzip gør sub_filter usynlig
Det her er den klassiske aha-fejl. Din upstream-app svarer som regel gzippet,
og sub_filter arbejder på den rå svar-body. Får den komprimerede bytes, finder
den aldrig </head>, og der sker .. ingenting. Ingen fejl, bare tavshed.
Løsningen er linjen proxy_set_header Accept-Encoding "";. Den fortæller upstream
at du ikke vil have komprimering, så nginx får ren HTML at arbejde på. Husk den,
ellers leder du efter spøgelser.
sub_filter_types text/html; sikrer samtidig, at vi kun rører HTML og ikke
forsøger at rode i JSON, billeder eller dine JavaScript-filer.
Fælde 2: CSP blokerer dit tredjepartsscript
Mange apps sender en Content-Security-Policy, der kun tillader scripts fra
deres eget origin. Injicerer du et <script src="https://assets.example.dk/...">,
bliver det blokeret af browseren. Tricket er at gøre scriptet same-origin: lad
proxyen servere det fra en sti på selve domænet og hente det bagved fra din
asset-host.
# Servér injector-scriptet same-origin, så CSP ikke blokerer det
location = /_inject/injector.js {
proxy_pass https://assets.example.dk/injector.js?origin=$host;
proxy_set_header Host assets.example.dk;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SNI når vi henter over HTTPS
proxy_ssl_server_name on;
proxy_ssl_name assets.example.dk;
proxy_buffering off;
add_header Cache-Control "public, max-age=60";
}
Nu peger mit injicerede script på /_inject/injector.js på samme domæne som
siden selv, og CSP er glad. Bag kulissen henter proxyen den rigtige fil fra
assets.example.dk og sender ?origin=$host med, så scriptet ved hvilken side det
kører på.
Gør injektionen dynamisk med Lua
Hvis du injicerer på alle hosts, så opstår der ét problem: du kommer også til at
injicere ind i din analytics-host selv, og i asset-hosten der serverer scriptet.
Det vil du ikke. Med et set_by_lua_block kan jeg bygge script-strengen dynamisk og
returnere en tom streng for de hosts, der skal holdes fri:
set_by_lua_block $inject_script {
local host = ngx.var.host or ""
-- Injicér intet på asset- og analytics-hosten (undgår loops)
if host == "assets.example.dk" or host == "matomo.example.dk" then
return ""
end
return "<script src='/_inject/injector.js' defer></script>"
}
sub_filter '</head>' '$inject_script</head>';
sub_filter_once on;
sub_filter_types text/html;
sub_filter kan godt indsætte en nginx-variabel, så $inject_script bliver til
enten et script-tag eller ingenting, afhængigt af hvilken host der svarer.
Bonus: en first-party cookie på tværs af subdomæner
Vil du følge den samme besøgende på tværs af app1.example.dk,
app2.example.dk osv., så kan du sætte en first-party cookie på todomænet med
header_filter_by_lua_block. Pointen er at sætte den på .example.dk, så den
deles, og kun på rigtige sidevisninger (ikke baggrundskald), så du undgår en
Set-Cookie-løkke:
header_filter_by_lua_block {
local cookie = ngx.var.http_cookie or ""
local vid = cookie:match("VID=([^;]+)")
local is_new = false
if not vid or vid == "" then
vid = ngx.var.request_id
is_new = true
end
-- Sæt kun cookien på spritny besøgende eller ægte HTML-sideload
local accept = ngx.var.http_accept or ""
local is_html = string.find(accept, "text/html")
if is_new or is_html then
local secure = (ngx.var.scheme == "https") and "; Secure" or ""
local sc = "VID=" .. vid ..
"; Domain=.example.dk; Path=/; Max-Age=157680000; HttpOnly; SameSite=Lax" ..
secure
local existing = ngx.header["Set-Cookie"]
if existing == nil then
ngx.header["Set-Cookie"] = sc
elseif type(existing) == "table" then
table.insert(existing, sc)
ngx.header["Set-Cookie"] = existing
else
ngx.header["Set-Cookie"] = { existing, sc }
end
end
}
Max-Age=157680000 er fem år. Bemærk HttpOnly.. cookien er kun til server-side
sammenkædning, ikke til JavaScript.
Et par advarsler
- Hold dig til dine egne sider. At injicere scripts i HTML, du ikke ejer, er præcis den slags man bør lade være med. Det her er til mine selvhostede apps.
sub_filter_once onsikrer, at vi kun rammer det første</head>. Slå det fra, hvis du bevidst vil ramme flere forekomster, men det er sjældent det du vil.- Test med
curl -H "Accept-Encoding: identity"og kig efter dit tag, hvis det ikke dukker op i browseren. Så ved du hurtigt, om det er gzip eller CSP, der driller.
Når det først sidder globalt i server_proxy.conf, får hver ny app bag proxyen
analytics gratis, uden at jeg rører appen. Det er hele pointen med at gøre det i
edge-laget.