8. juni 2026

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.jssamme 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.

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 on sikrer, 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.

Kilder og videre læsning

Kommentarer