Changeset df7de6e in Klonkt
- Timestamp:
- 08/24/2026 03:42:41 PM (2 weeks ago)
- Branches:
- main
- Children:
- 1d5ffc0
- Parents:
- d7d2ede
- Files:
-
- 1 added
- 2 edited
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
src/services/ap-transport.js (added)
-
test/ssrf-allowlist.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
rd7d2ede rdf7de6e 19 19 import fs from 'fs'; 20 20 import path from 'path'; 21 import dns from 'dns';22 import net from 'net';23 21 import db from '../config/database.js'; 24 22 import HtmlSanitizerService from './HtmlSanitizerService.js'; … … 30 28 import * as Guardianship from './guardianship/index.js'; 31 29 import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection, PAGINA_GROOTTE, artiestUrl } from './ap-core.js'; 30 // Stap 3 van de opsplitsing (shaer-drc): het transport -- de SSRF-poort, de 31 // sleutels, HTTP Signatures, de bezorging met wachtrij en de ondertekende 32 // GET -- woont in ap-transport.js. Hier her-geëxporteerd zodat elke bestaande 33 // importeur blijft werken, hetzelfde patroon als de Guardianship-exports onderaan. 34 import { 35 safeFetch, getOrCreateKeys, deliver, fetchActor, 36 enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker, 37 anySigningSlug, verifyRequest, signedGetHeaders, signedGetJson, apGetJson, 38 } from './ap-transport.js'; 39 export { 40 safeFetch, getOrCreateKeys, deliver, fetchActor, 41 enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker, 42 verifyRequest, signedGetHeaders, signedGetJson, 43 }; 32 44 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken. 33 45 export { AP_CONTEXT, actorId, noteId, guessMediaType }; … … 59 71 // smuggle a dangerous scheme into a stored href/src (rendered in owner-only views). 60 72 61 // ── SSRF guard for outbound fetches ───────────────────────────────62 // Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so63 // every outbound fetch must refuse hosts that resolve to private/loopback ranges64 // (cloud metadata, internal services) — on the initial host AND each redirect hop.65 function isBlockedIp(ip) {66 if (!ip) return true;67 const v = net.isIP(ip);68 if (v === 4) {69 const o = ip.split('.').map(Number);70 return o[0] === 127 || o[0] === 10 || o[0] === 071 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)72 || (o[0] === 192 && o[1] === 168)73 || (o[0] === 169 && o[1] === 254)74 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT75 }76 if (v === 6) {77 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');78 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')79 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')80 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');81 }82 return true; // not an IP literal we recognise → refuse83 }84 /**85 * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine86 * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).87 *88 * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van89 * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat90 * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de91 * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij92 * iemand hem expliciet vult.93 *94 * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst95 * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort96 * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in97 * een dev-omgeving uitzetten, en dev-omgevingen worden productie.98 *99 * AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"100 */101 const AP_ALLOW_HOSTS = new Set(102 String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),103 );104 function isAllowedTestHost(u) {105 if (!AP_ALLOW_HOSTS.size) return false;106 return AP_ALLOW_HOSTS.has(u.host.toLowerCase());107 }108 async function assertPublicHost(hostname) {109 // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP110 // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de111 // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met112 // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input113 // die hier nooit aankwam.114 const naakt = String(hostname || '').replace(/^\[|\]$/g, '');115 if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }116 const addrs = await dns.promises.lookup(naakt, { all: true });117 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');118 }119 // One honest name on ALL outbound federation traffic (Robins vraag, 31-7):120 // safeFetch went out with the bare Node default before, and polite fediverse121 // citizens say who they are (some instances even refuse anonymous UAs). A122 // caller-provided User-Agent (the EmbedResolver) still wins.123 let _uaVer = '1.0';124 try { _uaVer = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url))).version || _uaVer; } catch { /* keep default */ }125 const KLONKT_UA = `Klonkt/${_uaVer} (+https://klonkt.com)`;126 127 export async function safeFetch(url, opts = {}, maxRedirects = 3) {128 let target = url;129 for (let hop = 0; ; hop++) {130 const u = new URL(target); // throws on malformed → caller's catch131 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');132 // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:133 // een omleiding naar een ANDER intern adres blijft geweigerd.134 if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);135 const r = await fetch(target, {136 ...opts,137 headers: { 'User-Agent': KLONKT_UA, ...(opts.headers || {}) },138 redirect: 'manual',139 signal: AbortSignal.timeout(8000),140 });141 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;142 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }143 return r;144 }145 }146 73 const MAX_OUTBOX = 20; 147 74 // Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH 148 75 // (square) player card. Bump this whenever the twitter:player card dimensions change. 149 76 const FEDI_CARD_VER = '2'; 150 151 // ── RSA keys per actor (lazy, cached in DB) ───────────────────────152 // Prepared lazily (NOT at module load) — the ap_keys table is created in153 // initializeDatabase(), which runs after this module is imported.154 let _sel, _ins;155 function keyStmts() {156 if (!_sel) {157 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');158 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');159 }160 return { sel: _sel, ins: _ins };161 }162 163 export function getOrCreateKeys(slug) {164 const { sel, ins } = keyStmts();165 const row = sel.get(slug);166 if (row) return row;167 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {168 modulusLength: 2048,169 publicKeyEncoding: { type: 'spki', format: 'pem' },170 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },171 });172 ins.run(slug, publicKey, privateKey);173 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };174 }175 77 176 78 // ── content negotiation ─────────────────────────────────────────── … … 1724 1626 } 1725 1627 1726 // ── HTTP Signatures + delivery ────────────────────────────────────1727 1628 const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; }; 1728 1629 // Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count … … 1740 1641 } 1741 1642 return out; 1742 }1743 1744 // Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).1745 export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {1746 const body = JSON.stringify(bodyObj);1747 const u = new URL(inboxUrl);1748 const date = new Date().toUTCString();1749 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');1750 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;1751 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');1752 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;1753 const r = await safeFetch(inboxUrl, {1754 method: 'POST',1755 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },1756 body,1757 });1758 return r.status;1759 }1760 1761 export async function fetchActor(url, opts = {}) {1762 // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn1763 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND1764 // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct1765 // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,1766 // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier1767 // accounts eindeloos geweigerd, en precies die vier geven 401 op een1768 // onbetekende GET (shaer-afq).1769 //1770 // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons1771 // actor-document op, en dat serveert Klonkt publiek.1772 //1773 // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.1774 // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die1775 // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek1776 // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten1777 // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is1778 // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus1779 // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.1780 let doc = null;1781 try {1782 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });1783 if (r.ok) {1784 const len = Number(r.headers.get('content-length') || 0);1785 if (len > 2_000_000) return null; // refuse oversized actor docs1786 doc = await r.json();1787 }1788 } catch { /* val door naar de ondertekende poging */ }1789 // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document1790 // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die1791 // telt als mislukt.1792 if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;1793 if (!opts.asSlug) return doc;1794 const signed = await signedGetJson(opts.asSlug, url).catch(() => null);1795 return (signed && signed.id) ? signed : doc;1796 }1797 1798 // ── Delivery queue with retries ───────────────────────────────────1799 // Outbound deliveries are tried immediately; on failure (down server, timeout,1800 // non-2xx) they're queued and retried with backoff so a briefly-offline follower1801 // doesn't silently miss the post. The signing key is NOT stored — the worker1802 // re-derives it from the actor slug at send time.1803 const DELIVERY_MAX_ATTEMPTS = 6;1804 const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];1805 let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;1806 function deliveryStmts() {1807 if (!_insDeliv) {1808 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');1809 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");1810 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');1811 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');1812 }1813 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };1814 }1815 export function enqueueDelivery(slug, inbox, activity) {1816 if (!slug || !inbox || !activity) return;1817 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }1818 }1819 // Record delivery health per follower so the followers list can flag dead accounts.1820 // Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them1821 // are marked. A non-follower inbox (inline @mention) simply matches 0 rows.1822 let _fDelivOk, _fDelivErr;1823 function markFollowerDelivery(slug, inbox, ok) {1824 if (!slug || !inbox) return;1825 try {1826 if (!_fDelivOk) {1827 _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');1828 _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');1829 }1830 (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);1831 } catch { /* health tracking is non-fatal */ }1832 }1833 // Deliver now; queue for retry if it fails.1834 export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {1835 if (!inbox) return;1836 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }1837 enqueueDelivery(slug, inbox, activity);1838 }1839 let _processingDeliv = false;1840 export async function processDeliveryQueue() {1841 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery1842 _processingDeliv = true;1843 try {1844 let rows;1845 try { rows = deliveryStmts().due.all(); } catch { return; }1846 if (!rows || !rows.length) return;1847 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');1848 for (const row of rows) {1849 let ok = false;1850 try {1851 const keys = getOrCreateKeys(row.slug);1852 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);1853 ok = st >= 200 && st < 300;1854 } catch { ok = false; }1855 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }1856 const attempts = row.attempts + 1;1857 if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }1858 // Index the backoff on the CURRENT attempt count (row.attempts) so the first1859 // retry uses the 1-min tier instead of skipping it.1860 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];1861 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);1862 }1863 } finally { _processingDeliv = false; }1864 }1865 let _delivTimer = null;1866 export function startDeliveryWorker() {1867 if (_delivTimer) return;1868 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);1869 if (_delivTimer.unref) _delivTimer.unref();1870 }1871 1872 /** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is1873 * (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */1874 let _signSlug;1875 function anySigningSlug() {1876 if (_signSlug !== undefined) return _signSlug;1877 try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }1878 catch { _signSlug = null; }1879 return _signSlug;1880 }1881 1882 // Best-effort verification of an incoming signed request. Returns the sender's1883 // actor doc if the signature checks out, else null. (Not gating yet — MVP.)1884 // Max clock skew for the signed Date header (replay window). Generous default to tolerate1885 // federating servers with drifting clocks; an operator can widen it via env.1886 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;1887 export async function verifyRequest(req, asSlug = null) {1888 const sigH = req.headers['signature'];1889 if (!sigH) return null;1890 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));1891 if (!p.keyId || !p.signature) return null;1892 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized1893 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we1894 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige1895 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.1896 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });1897 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;1898 if (!pem) return null;1899 // Bind the key to the actor it speaks for. Without this we hand back whatever1900 // `id` the fetched document claims, so anyone could host a document carrying a1901 // VICTIM's id next to their OWN public key, sign with their own private half,1902 // and be believed: the victim's server is never contacted. The caller decides on1903 // `verified.id`, so the identity has to come from where the key was FETCHED,1904 // never from what the document says about itself.1905 // Adds conditions only, and there is no exemption list on purpose: an1906 // "unless it's a known peer" escape hatch is exactly the door this closes.1907 // Note this does not narrow what we accept in practice, since the line above1908 // already requires the embedded publicKey object (an array or a bare URI1909 // reference never worked here).1910 const key = actor.publicKey;1911 try {1912 if (new URL(p.keyId).host !== new URL(actor.id).host) return null; // same origin as the key1913 if (key.id && key.id !== p.keyId) return null; // this key, not a neighbour's1914 if (key.owner && key.owner !== actor.id) return null; // and it belongs to this actor1915 } catch { return null; } // unparseable id or keyId1916 const hs = (p.headers || '(request-target) host date').split(/\s+/);1917 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when1918 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the1919 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL1920 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies1921 // against any. An attacker can't forge a match (no private key), so this only rescues the1922 // legitimate proxied case. Also normalise a leading double-slash in the request-target.1923 let _pubHost = null;1924 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }1925 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];1926 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;1927 const _sig = Buffer.from(p.signature, 'base64');1928 let ok = false;1929 for (const _h of _hosts) {1930 const line = hs.map((x) => x === '(request-target)'1931 ? `(request-target): ${_target}`1932 : x === 'host' ? `host: ${_h}`1933 : `${x}: ${req.headers[x] || ''}`).join('\n');1934 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }1935 }1936 // Replay defence: the Date header must be signed and recent. A captured signed request1937 // replayed later (or with a swapped body) is rejected.1938 if (ok) {1939 if (!hs.includes('date')) ok = false;1940 else {1941 const t = Date.parse(req.headers['date'] || '');1942 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;1943 }1944 }1945 // Digest is MANDATORY when the request carries a body: without a signed digest the body1946 // isn't covered by the signature and could be swapped on a replay.1947 if (ok && req.rawBody && req.rawBody.length) {1948 if (!hs.includes('digest')) ok = false;1949 else {1950 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');1951 if (req.headers['digest'] !== exp) ok = false;1952 }1953 }1954 return ok ? actor : null;1955 1643 } 1956 1644 … … 5671 5359 } 5672 5360 5673 // A generic SSRF-safe AP GET (collections / pages).5674 /**5675 * A signed GET as one of our local actors (friends-history, 30-7): the remote5676 * server can then recognise the caller and serve what THAT caller may see,5677 * exactly like the guardian's authorized fetch. The signature covers5678 * (request-target) host date, the set verifyRequest checks.5679 */5680 /**5681 * De handtekening-headers voor een GET als `slug`. Losgetrokken uit5682 * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:5683 * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch5684 * krijgt daar terecht een 403).5685 */5686 export function signedGetHeaders(slug, url, accept = 'application/activity+json') {5687 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');5688 if (!base || !slug) return null;5689 const me = actorId(base, slug);5690 const keys = getOrCreateKeys(slug);5691 const u = new URL(url);5692 const date = new Date().toUTCString();5693 const target = `${u.pathname}${u.search || ''}`;5694 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;5695 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');5696 return {5697 Accept: accept,5698 Date: date,5699 Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,5700 };5701 }5702 5703 export async function signedGetJson(slug, url, onStatus) {5704 try {5705 const headers = signedGetHeaders(slug, url);5706 if (!headers) return apGetJson(url);5707 const r = await safeFetch(url, { headers });5708 // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte5709 // niet", en dat is te weinig om een WEIGERING van een STORING te5710 // onderscheiden. Wie geen callback meegeeft merkt hier niets van.5711 if (typeof onStatus === 'function') onStatus(r.status);5712 if (!r.ok) return null;5713 const len = Number(r.headers.get('content-length') || 0);5714 if (len > 3_000_000) return null;5715 return await r.json();5716 } catch { return null; }5717 }5718 5719 async function apGetJson(url) {5720 try {5721 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });5722 if (!r.ok) return null;5723 const len = Number(r.headers.get('content-length') || 0);5724 if (len > 3_000_000) return null;5725 return await r.json();5726 } catch { return null; }5727 }5728 5361 // AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent 5729 5362 // top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill -
test/ssrf-allowlist.test.js
rd7d2ede rdf7de6e 51 51 // De lijst is leeg tenzij iemand hem expliciet vult. Dit is de stand waarin 52 52 // elke productie-instance draait. 53 // 54 // De verse import moet het bestand raken waar AP_ALLOW_HOSTS bij het LADEN 55 // wordt bevroren, en dat is sinds de opsplitsing ap-transport.js. Een 56 // gebuste import van ActivityPubService.js zou niets bewijzen: zijn eigen 57 // `import './ap-transport.js'` (zonder query) komt uit de modulecache, met 58 // de lijst van hierboven er nog in. 53 59 const eerder = process.env.AP_ALLOW_HOSTS; 54 60 delete process.env.AP_ALLOW_HOSTS; 55 const mod = await import(`../src/services/ ActivityPubService.js?leeg=${Date.now()}`);61 const mod = await import(`../src/services/ap-transport.js?leeg=${Date.now()}`); 56 62 let fout = null; 57 63 try { await mod.safeFetch('http://[::1]:59321/niets'); } catch (e) { fout = e.message; }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)