Il contesto

Il pannello ordini interno gestisce le spedizioni dei prodotti POS verso i clienti finali. Fino a questo momento le etichette di spedizione venivano generate manualmente dal portale del corriere. L'obiettivo era automatizzare il flusso: quando un ordine viene marcato come completato, il sistema deve chiamare automaticamente le API MBE (Mail Boxes Etc.) per generare l'etichetta, salvare il tracking su Firebase e scaricare il PDF.

MBE espone un web service SOAP chiamato e-Link. Sulla carta il lavoro sembrava semplice: una Netlify Function che costruisce una busta SOAP, la invia, riceve tracking e PDF in base64. Nella pratica si è trasformato in tre problemi distinti e indipendenti, ognuno mascherato da un sintomo simile — errori 403 e risposte vuote — che hanno richiesto un debug sistematico per essere isolati.

Problema #1 — il limite invisibile di 4KB su AWS Lambda

La prima versione della Netlify Function falliva in deploy senza un motivo apparente. Le altre function del progetto, già in produzione, usavano tutte il formato Netlify Functions v2 (export default async (req) => ...), mentre la mia era stata scritta nel vecchio formato Lambda compatibile (exports.handler). Con quel formato, Netlify ricade sul runtime AWS Lambda classico, che impone un limite di 4KB sulla dimensione totale di tutte le variabili d'ambiente della funzione — non solo quelle nuove, ma anche quelle già esistenti per le altre integrazioni del progetto (chiave di servizio Firebase, chiave privata e certificato di un altro gateway di pagamento).

Riscrivere la function nel formato v2 ESM ha eliminato il vincolo di runtime, ma il deploy continuava a fallire — il node_bundler impostato su esbuild nel file di configurazione Netlify stava ricompilando il codice ESM in CommonJS, rimettendo la function in modalità Lambda compatibile nonostante la sintassi v2. Il fix è stato cambiare il bundler in nft (Node File Trace), quello nativo per le function v2, che lascia il codice ESM intatto.

netlify.toml — bundler corretto per function v2 ESM
[build]
  functions = "netlify/functions"

[functions]
  # esbuild ricompila ESM → CommonJS e reintroduce
  # il limite 4KB env var della Lambda classica.
  node_bundler = "nft"  # era "esbuild"
⚠️

Anche con il bundler corretto, il totale delle variabili d'ambiente del progetto era già al limite. La soluzione definitiva è stata fare pulizia: alcune variabili di un'integrazione precedente (un certificato PEM da ~1KB tra queste) non erano più usate da nessuna function attiva. Rimuoverle ha liberato margine sufficiente per le nuove credenziali MBE senza dover spostare nulla in un secrets manager esterno.

Problema #2 — 403 ovunque, anche da un VPS dedicato

Con il deploy finalmente passato, la function chiamava correttamente l'endpoint MBE ma riceveva sempre un 403 Forbidden con body vuoto. Il sospetto iniziale è stato un classico blocco anti-bot: molti servizi B2B filtrano le richieste provenienti da intervalli IP noti dei provider cloud (AWS, GCP, Azure), perché il loro sistema è pensato per essere chiamato da software installato su PC fisici, non da server.

Per verificarlo ho testato la stessa identica richiesta da quattro ambienti diversi:

  • Netlify Function (IP dinamico AWS) → 403
  • Worker su un edge proxy di terze parti (IP diverso da AWS) → 403
  • Connessione mobile 4G da rete domestica, via client REST grafico → 403
  • VPS dedicato con IP fisso europeo, via curl → 403

Stesso codice 403, stesso cookie di sessione nella risposta, su quattro reti completamente diverse — inclusa una connessione mobile residenziale che in teoria non dovrebbe essere filtrata da un WAF anti-datacenter. Questo è stato il primo indizio che la diagnosi iniziale era sbagliata: non era un blocco per categoria di IP.

curl -v — connessione TLS riuscita, blocco applicativo dopo l'handshake
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/2 403
< content-length: 0
< cache-control: no-cache, no-store, max-age=0, must-revalidate
< x-content-type-options: nosniff
< strict-transport-security: max-age=31536000 ; includeSubDomains
< x-frame-options: DENY
< set-cookie: 29db36aa5f07e32c0e7ffa7ce589b780=...; HttpOnly; Secure; SameSite=None
💡

Una connessione TLS 1.3 che si chiude correttamente, seguita da un 403 con header tipici (x-frame-options, strict-transport-security) e un cookie di sessione, è la firma di un Web Application Firewall a livello applicativo, non di un firewall di rete. Il blocco arriva dopo l'handshake, sull'analisi della richiesta — un dettaglio che si sarebbe rivelato decisivo nel passaggio successivo.

La svolta — la documentazione non corrispondeva all'API reale

Dopo aver scartato l'ipotesi del blocco IP, la mossa decisiva è stata contattare direttamente l'assistenza tecnica MBE, che ha risposto: nessun blocco IP esiste sul loro sistema, e ha allegato un esempio reale di chiamata funzionante. Confrontando quell'esempio con la struttura usata fino a quel momento (basata sul WSDL pubblico), le differenze erano sostanziali:

Campo WSDL pubblico (non funzionante) Esempio reale del supporto
Endpoint /ws/e-link /ws
Namespace onlinembe.it/ws/ onlinembe.eu/ws/
Operazione ShipmentCreateRequest ShipmentRequest
Autenticazione Credenziali nel body XML Header HTTP Authorization: Basic
Destinatario RecipientData Recipient

Il vero colpevole del 403 non era mai stato un firewall geografico: era il WAF che rifiutava in toto richieste con un namespace, un'operazione e un metodo di autenticazione che la sua configurazione applicativa non riconosceva — bloccandole con la stessa risposta vuota che un blocco IP avrebbe prodotto. Il sintomo era identico in tutti e quattro gli ambienti perché l'errore era nel payload, non nella rete.

SOAP con Basic Auth HTTP da Node.js

Con namespace, operazione e header corretti, la prima chiamata di test ha restituito finalmente HTTP 200 con tracking e PDF in base64 dentro la risposta. La Netlify Function costruisce la busta SOAP e imposta l'header Basic Auth manualmente, senza nessuna libreria SOAP esterna:

JavaScript — chiamata SOAP con Basic Auth (Netlify Function v2)
export default async (req) => {
  const { recipient, shipment } = await req.json();

  const AUTH = 'Basic ' + Buffer.from(
    `${process.env.MBE_USERNAME}:${process.env.MBE_PASSPHRASE}`
  ).toString('base64');

  const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:ns1="http://www.onlinembe.eu/ws/">
  <SOAP-ENV:Body>
    <ns1:ShipmentRequest>
      <RequestContainer>
        <System>IT</System>
        <Recipient>...</Recipient>
        <Shipment>...</Shipment>
      </RequestContainer>
    </ns1:ShipmentRequest>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>`;

  const resp = await fetch('https://api.mbeonline.it/ws', {
    method: 'POST',
    headers: {
      'Content-Type': 'text/xml; charset=UTF-8',
      'Authorization': AUTH
    },
    body: soapEnvelope
  });

  const xml = await resp.text();
  // parsing tracking + PDF da <MasterTrackingMBE> e <Stream>
  return Response.json({ ok: true, xml });
};

Problema #3 — un secondo blocco, questa volta reale

Risolta la struttura SOAP, la chiamata funzionava perfettamente da postazioni di test (client REST grafico, curl da rete mobile). Ma chiamandola dalla Netlify Function in produzione, il 403 è tornato — stavolta per un motivo distinto e reale: il sistema MBE applica comunque un controllo sulla provenienza della richiesta a livello infrastrutturale per le chiamate automatizzate continuative, indipendentemente dal payload corretto.

A questo punto la soluzione architetturale più solida è stata spostare la chiamata SOAP fuori da Netlify: un piccolo proxy Node.js, in esecuzione su un VPS economico con IP fisso dedicato, riceve la richiesta dalla Netlify Function, la inoltra a MBE con le credenziali corrette, e restituisce la risposta. Il proxy gira come servizio systemd sempre attivo, con riavvio automatico in caso di crash.

server.js — proxy Node.js puro (VPS, porta 3001)
import http from 'http';
import https from 'https';

const PORT       = 3001;
const TOKEN      = process.env.PROXY_TOKEN; // auth tra Netlify e VPS
const BASIC_AUTH = 'Basic ' + Buffer.from(
  `${process.env.MBE_USERNAME}:${process.env.MBE_PASSPHRASE}`
).toString('base64');

http.createServer(async (req, res) => {
  if (req.headers['x-proxy-token'] !== TOKEN) {
    res.writeHead(401); res.end(); return;
  }
  const chunks = [];
  for await (const c of req) chunks.push(c);

  const mbeReq = https.request({
    hostname: 'api.mbeonline.it', path: '/ws', method: 'POST',
    headers: { 'Content-Type': 'text/xml; charset=UTF-8', 'Authorization': BASIC_AUTH }
  }, mbeRes => {
    const out = [];
    mbeRes.on('data', c => out.push(c));
    mbeRes.on('end', () => {
      res.writeHead(mbeRes.statusCode, { 'Content-Type': 'text/xml' });
      res.end(Buffer.concat(out));
    });
  });
  mbeReq.end(Buffer.concat(chunks));
}).listen(PORT);
💡

Il proxy non interpreta né valida il payload — fa solo da relay TCP/TLS verso MBE, aggiungendo l'header di autenticazione. Questo lo rende riutilizzabile per qualunque altra integrazione con lo stesso vincolo di provenienza IP, semplicemente aggiungendo una nuova route. È stato esteso poco dopo per risolvere un problema identico con un altro gateway di pagamento che richiedeva un IP pubblico fisso e HTTPS diretto.

Cosa abbiamo imparato

  1. Stesso codice HTTP, cause diverse. Un 403 con body vuoto può nascere da un payload SOAP rifiutato dal WAF, da un blocco IP infrastrutturale, o da entrambi in sequenza. Cambiare ambiente di test (rete mobile, VPS, edge proxy) è il modo più rapido per isolare quale dei due sta succedendo.
  2. Il WSDL pubblico non è sempre la verità. Documentazione tecnica esposta pubblicamente può essere obsoleta rispetto all'API realmente in produzione. Quando una struttura "da manuale" continua a fallire, chiedere all'assistenza un esempio di chiamata funzionante battendo la documentazione ufficiale.
  3. Il limite 4KB di AWS Lambda è cumulativo, non per-funzione. Su Netlify, il bundler esbuild può silenziosamente far ricadere una function v2 ESM nel runtime Lambda classico. Verificare sempre node_bundler nel netlify.toml prima di sospettare altro.
  4. Un proxy su VPS è una rete di sicurezza generica. Quando un fornitore SaaS richiede un IP pubblico fisso o blocca le richieste serverless, un micro-proxy Node.js su un VPS economico è una soluzione riutilizzabile per qualunque integrazione futura con lo stesso vincolo.
  5. Testare con strumenti diversi accelera la diagnosi. Alternare un client REST grafico e chiamate curl da riga di comando ha permesso di isolare problemi di handshake TLS, header duplicati e versione HTTP che altrimenti sarebbero rimasti nascosti in un solo strumento.

Domande frequenti

Perché il deploy della Netlify Function falliva con un limite di 4KB anche dopo aver rimosso il service account precedente?

Il totale delle variabili d'ambiente del progetto era già al limite anche senza il PEM del service account. La soluzione definitiva è stata fare pulizia: alcune variabili di un'integrazione precedente non più in uso (incluso un certificato PEM da circa 1KB) occupavano spazio inutilmente, e rimuoverle ha liberato margine sufficiente per le nuove credenziali.

Come si distingue un blocco IP anti-bot da un Web Application Firewall a livello applicativo quando si riceve un 403?

Testando la stessa richiesta da reti completamente diverse (cloud, edge proxy, mobile 4G, VPS dedicato): se il 403 persiste ovunque, non è un blocco per categoria di IP. Una connessione TLS che si chiude correttamente seguita da un 403 con header tipici (x-frame-options, strict-transport-security) e un cookie di sessione è la firma di un WAF applicativo, che blocca dopo l'handshake analizzando la richiesta.

Perché una chiamata SOAP costruita seguendo il WSDL pubblico veniva comunque rifiutata dall'API reale?

La documentazione WSDL pubblica non corrispondeva più all'API effettivamente in uso dal provider: endpoint, namespace e nomi delle operazioni erano cambiati. Confrontare la struttura usata con un esempio reale fornito dall'assistenza tecnica ha rivelato le differenze esatte, mostrando che il problema non era mai stato un blocco di rete ma un payload SOAP non valido per la versione corrente dell'API.