mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(collab): harden the link preview fetcher (#2058)
The preview endpoint takes a URL from whoever is typing in a trip chat and fetches it from the server. Trip access was added to it earlier; this is the rest of what that route needed. The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0` but not the IPv6 unspecified address: `::` starts with a colon, so the `0.` check never saw it, and connecting to it lands on loopback. `http://[::]:9200/` therefore passed the guard and came back with the target's title and og: tags scraped out of the body — an exfiltrating read of anything listening locally, from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same address in other spellings and behaved the same way. The two `::ffff:`-prefix regexes had the same shape of gap: they matched the dotted spelling of loopback and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on the expanded hextets, which covers every spelling at once, and fe80::/10 is read as ten bits rather than as the four characters 'fe80'. This is in ssrfGuard, so every caller gets it, not just collab. The scrape could be turned into a denial of service. Each og: regex had two unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it made the engine rescan the remainder from every `<meta` it passed — quadratic, and the byte cap admits half a megabyte. Measured on this build: 240KB of '<meta ' took 58 seconds, and Node has one thread, so that is the entire server, health check included, on one request. Bounding the runs at 512 characters makes it linear (540KB now takes 1.4s); no real attribute list comes close to that. The five-second timeout only covered the headers — `clearTimeout` ran before the body was read — so a server that answered fast and then dripped one byte every few seconds held the handler, the socket and the per-request undici agent open indefinitely. The byte cap counts bytes and at that rate would never reach one. `AbortSignal.timeout` covers both halves, the agent is closed in a `finally`, and the three bail-outs that used to return without touching the body now discard it, which is what cappedFetch's helper is there for. The rest is smaller. Nothing metered the fetches, so an account with a trip of its own could drive arbitrary outbound requests in a loop; there is a budget of sixty new fetches a minute per user now, charged per outbound fetch rather than per request. Previews are cached for ten minutes and simultaneous askers for one URL share a single fetch, so the client's habit of requesting a preview per rendered message — up to a hundred at once, with no debounce — costs one fetch per new link rather than one per render. Refusals answer with one constant reason instead of the guard's own: it distinguishes "could not resolve" from "private address", which together map out the server's internal DNS for anyone willing to guess hostnames. A body whose declared type is not markup is dropped rather than combed for og: tags, and an og:image that is not an http(s) URL is not passed to the client, which puts it straight into an <img src>. Deliberately no `@RequirePermission` on the route: the client asks for a preview while rendering a message or a note, never while composing one, so it belongs with the GET siblings. Requiring collab_edit would blank every preview for members of a trip whose owner narrowed that right while leaving them able to read the chat, and it would not stop the abuse it looks like it addresses — the permission check passes for a trip's owner at every level, and anyone can create a trip.
This commit is contained in:
@@ -299,6 +299,13 @@ export class CollabController {
|
||||
}
|
||||
|
||||
// ── Link preview ──────────────────────────────────────────────────────────
|
||||
// Deliberately no @RequirePermission: this is a read path. The client asks for
|
||||
// a preview while *rendering* a message or a note (CollabChatMessages,
|
||||
// CollabNotesCard), never while composing one, so it belongs with the GET
|
||||
// siblings above — requiring 'collab_edit' would strip every preview from a
|
||||
// trip whose owner has narrowed that right, for people who may still read the
|
||||
// chat. What keeps the fetcher from being a free outbound proxy is the budget
|
||||
// inside the service, not a write permission.
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get('link-preview')
|
||||
async linkPreview(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('url') url?: string) {
|
||||
@@ -308,10 +315,12 @@ export class CollabController {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
try {
|
||||
const preview = await this.collab.linkPreview(url);
|
||||
const asRecord = preview as { error?: string };
|
||||
if (asRecord.error) {
|
||||
throw new HttpException({ error: asRecord.error }, 400);
|
||||
const preview = await this.collab.linkPreview(url, user.id);
|
||||
if (preview.rateLimited) {
|
||||
throw new HttpException({ error: 'Too many requests' }, 429);
|
||||
}
|
||||
if (preview.error) {
|
||||
throw new HttpException({ error: preview.error }, 400);
|
||||
}
|
||||
return preview;
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,7 +30,7 @@ import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
|
||||
}),
|
||||
}),
|
||||
StorageModule,
|
||||
McpSharedModule, NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
|
||||
McpSharedModule, NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule, RateLimitModule],
|
||||
controllers: [CollabController],
|
||||
providers: [CollabService, CollabMcp, CollabRpc],
|
||||
// For in-container consumers (CollabRpc).
|
||||
|
||||
@@ -6,10 +6,11 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../../utils/ssrfGuard';
|
||||
import { exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
|
||||
import { discardBody, exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
|
||||
import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../../types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -51,6 +52,56 @@ export interface LinkPreviewResult {
|
||||
image: string | null;
|
||||
site_name?: string | null;
|
||||
url: string;
|
||||
/** Set when the URL was refused; the controller turns it into a 400. */
|
||||
error?: string;
|
||||
/** Set when the caller is out of preview fetches; the controller turns it into a 429. */
|
||||
rateLimited?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound fetches one user may trigger per minute. Deliberately generous: the
|
||||
* client asks for a preview per rendered message (`LIMIT 100`) and per note with
|
||||
* a website, both without debounce, so opening a link-heavy trip is a burst of
|
||||
* dozens. Cache hits are not charged, so this only ever meters *new* URLs.
|
||||
*/
|
||||
const PREVIEW_FETCHES_PER_MINUTE = 60;
|
||||
|
||||
/** How long a scraped preview stays good. og: tags are near-static. */
|
||||
const PREVIEW_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Entries kept before the least recently used one is dropped. */
|
||||
const PREVIEW_CACHE_MAX = 500;
|
||||
|
||||
/**
|
||||
* Pulls the og:/<title>/description fields out of a document.
|
||||
*
|
||||
* Every `[^>]` run is bounded. Unbounded, two of them separated by a literal make
|
||||
* the engine rescan the rest of the document from every `<meta` it passes, which
|
||||
* is quadratic: a page of `'<meta '` with no `>` in it took ~58s at 240KB on the
|
||||
* measured build, and the cap admits half a megabyte. Node runs one thread, so
|
||||
* that is the whole server — WebSocket, auth and health included — for one
|
||||
* request. No real attribute list comes close to 512 characters.
|
||||
*/
|
||||
function scrapeOpenGraph(html: string): Omit<LinkPreviewResult, 'url'> {
|
||||
const og = (prop: string) => {
|
||||
const m = html.match(new RegExp(`<meta[^>]{0,512}property=["']og:${prop}["'][^>]{0,512}content=["']([^"']*)["']`, 'i'))
|
||||
|| html.match(new RegExp(`<meta[^>]{0,512}content=["']([^"']*)["'][^>]{0,512}property=["']og:${prop}["']`, 'i'));
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const titleTag = html.match(/<title[^>]{0,512}>([^<]*)<\/title>/i);
|
||||
const descMeta = html.match(/<meta[^>]{0,512}name=["']description["'][^>]{0,512}content=["']([^"']*)["']/i)
|
||||
|| html.match(/<meta[^>]{0,512}content=["']([^"']*)["'][^>]{0,512}name=["']description["']/i);
|
||||
const image = og('image');
|
||||
|
||||
return {
|
||||
title: og('title') || (titleTag ? titleTag[1].trim() : null),
|
||||
description: og('description') || (descMeta ? descMeta[1].trim() : null),
|
||||
// The client renders this straight into an <img src>, so the page being
|
||||
// previewed must not be able to point that at anything but a web address —
|
||||
// the same scheme pin placeImageUrlSchema applies to a stored place picture.
|
||||
image: image && /^https?:\/\//i.test(image) ? image : null,
|
||||
site_name: og('site_name') || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,8 +126,23 @@ export class CollabService {
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly rateLimit: RateLimitService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Scraped previews, keyed by URL and ordered least-recently-used first.
|
||||
*
|
||||
* On the service rather than at module scope so a test gets a fresh one per
|
||||
* container. What it buys: the client re-requests every preview it renders on
|
||||
* each mount, so without it a reload of a busy trip is another hundred outbound
|
||||
* fetches — the exact traffic the budget above is meant to stop the server from
|
||||
* emitting on someone else's behalf.
|
||||
*/
|
||||
private readonly previewCache = new Map<string, { at: number; result: LinkPreviewResult }>();
|
||||
|
||||
/** Preview fetches currently in flight, so simultaneous askers share one request. */
|
||||
private readonly inFlight = new Map<string, Promise<LinkPreviewResult>>();
|
||||
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
@@ -461,60 +527,114 @@ export class CollabService {
|
||||
/* Link preview */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async linkPreview(url: string): Promise<LinkPreviewResult> {
|
||||
async linkPreview(url: string, userId?: number): Promise<LinkPreviewResult> {
|
||||
const fallback: LinkPreviewResult = { title: null, description: null, image: null, url };
|
||||
|
||||
// A malformed URL returns the fallback directly (the legacy code let
|
||||
// `new URL` throw and relied on the controller's catch for the same 200).
|
||||
try { new URL(url); } catch { return fallback; }
|
||||
|
||||
// Served before the budget is charged: opening a chat re-requests every
|
||||
// preview it renders, so a reload must not cost the caller its allowance.
|
||||
const cached = this.readPreviewCache(url);
|
||||
if (cached) return cached;
|
||||
|
||||
// A fetch for this URL is already on its way. The client renders one preview
|
||||
// per message and does not deduplicate, so the same link posted twenty times
|
||||
// arrives as twenty simultaneous requests — none of which would find a cache
|
||||
// entry yet, since the first has not answered. Joining the running fetch keeps
|
||||
// that a single outbound request instead of twenty.
|
||||
const running = this.inFlight.get(url);
|
||||
if (running) return { ...(await running), url };
|
||||
|
||||
// Charged per outbound fetch rather than per request, which is what the
|
||||
// budget is actually protecting. Without a user there is no one to charge —
|
||||
// no caller passes that today, and the fetch stays behind the SSRF guard.
|
||||
if (userId !== undefined && !this.rateLimit.check('collab_link_preview', String(userId), PREVIEW_FETCHES_PER_MINUTE, 60_000, Date.now())) {
|
||||
return { ...fallback, rateLimited: true };
|
||||
}
|
||||
|
||||
const task = this.fetchPreview(url, fallback);
|
||||
this.inFlight.set(url, task);
|
||||
try {
|
||||
return await task;
|
||||
} finally {
|
||||
this.inFlight.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
/** The outbound half of linkPreview, past the cache and the budget. */
|
||||
private async fetchPreview(url: string, fallback: LinkPreviewResult): Promise<LinkPreviewResult> {
|
||||
const ssrf = await checkSsrf(url, true);
|
||||
if (!ssrf.allowed) {
|
||||
return { ...fallback, error: ssrf.error } as LinkPreviewResult & { error?: string };
|
||||
// The caller learns that the URL was refused, never why: the three distinct
|
||||
// reasons ("could not resolve", "private address", "loopback") would together
|
||||
// map out the server's internal DNS for anyone willing to guess hostnames.
|
||||
return { ...fallback, error: 'URL not allowed' };
|
||||
}
|
||||
|
||||
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
|
||||
} as any);
|
||||
clearTimeout(timeout);
|
||||
if (!r.ok) throw new Error('Fetch failed');
|
||||
if (exceedsDeclaredLength(r, MAX_PREVIEW_BYTES)) return fallback;
|
||||
|
||||
// A truncated head still carries the tags we scrape, so a page over the
|
||||
// budget degrades to fewer fields rather than to an error.
|
||||
const { text: html } = await readCappedText(r, MAX_PREVIEW_BYTES);
|
||||
const get = (prop: string) => {
|
||||
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|
||||
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||||
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|
||||
|| html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
|
||||
|
||||
return {
|
||||
title: get('title') || (titleTag ? titleTag[1].trim() : null),
|
||||
description: get('description') || (descMeta ? descMeta[1].trim() : null),
|
||||
image: get('image') || null,
|
||||
site_name: get('site_name') || null,
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return fallback;
|
||||
// AbortSignal.timeout covers the body as well. The hand-rolled controller
|
||||
// this replaces was cleared as soon as the headers arrived, so a server that
|
||||
// answered fast and then dripped the body one byte at a time held the handler,
|
||||
// the socket and this dispatcher open indefinitely — the byte cap counts
|
||||
// bytes, and at that rate it would never reach one.
|
||||
const r = await fetch(url, {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
dispatcher,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
|
||||
} as any);
|
||||
if (!r.ok) { discardBody(r); return this.cachePreview(url, fallback); }
|
||||
// Only markup is worth scraping. A declared type that is not HTML means the
|
||||
// regexes below would comb a video or an archive for og: tags and find nothing.
|
||||
const type = r.headers?.get('content-type') ?? '';
|
||||
if (type && !/^\s*(text\/html|application\/xhtml\+xml|text\/plain)\b/i.test(type)) {
|
||||
discardBody(r);
|
||||
return this.cachePreview(url, fallback);
|
||||
}
|
||||
// An unread body keeps its socket reserved until the garbage collector runs,
|
||||
// which is the one thing a size cap is there to prevent.
|
||||
if (exceedsDeclaredLength(r, MAX_PREVIEW_BYTES)) { discardBody(r); return this.cachePreview(url, fallback); }
|
||||
|
||||
// A truncated head still carries the tags we scrape, so a page over the
|
||||
// budget degrades to fewer fields rather than to an error.
|
||||
const { text: html } = await readCappedText(r, MAX_PREVIEW_BYTES);
|
||||
return this.cachePreview(url, { ...scrapeOpenGraph(html), url });
|
||||
} catch {
|
||||
return fallback;
|
||||
return this.cachePreview(url, fallback);
|
||||
} finally {
|
||||
// Closed rather than left to the garbage collector: one Agent is built per
|
||||
// preview, and each keeps its sockets until something releases them.
|
||||
void (dispatcher as { close?: () => Promise<void> } | undefined)?.close?.()?.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** A cached preview, or undefined once its entry has expired or was never there. */
|
||||
private readPreviewCache(url: string): LinkPreviewResult | undefined {
|
||||
const hit = this.previewCache.get(url);
|
||||
if (!hit) return undefined;
|
||||
if (Date.now() - hit.at > PREVIEW_CACHE_TTL_MS) {
|
||||
this.previewCache.delete(url);
|
||||
return undefined;
|
||||
}
|
||||
// Re-insert so the eviction below drops the least recently used entry.
|
||||
this.previewCache.delete(url);
|
||||
this.previewCache.set(url, hit);
|
||||
return { ...hit.result, url };
|
||||
}
|
||||
|
||||
/** Stores a preview and returns it, so call sites can `return this.cachePreview(...)`. */
|
||||
private cachePreview(url: string, result: LinkPreviewResult): LinkPreviewResult {
|
||||
if (this.previewCache.size >= PREVIEW_CACHE_MAX) {
|
||||
const oldest = this.previewCache.keys().next().value;
|
||||
if (oldest !== undefined) this.previewCache.delete(oldest);
|
||||
}
|
||||
this.previewCache.set(url, { at: Date.now(), result });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fire-and-forget collab notification (mirrors the legacy route's dynamic import). */
|
||||
notifyCollab(tripId: string, actor: User, preview?: string): void {
|
||||
// Injected, not a lazy import of the old notifications bridge. The laziness bought
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dns from 'node:dns/promises';
|
||||
import { Agent } from 'undici';
|
||||
import { readEnv } from '../app-config';
|
||||
import { embeddedTransitionIpv4 } from './ipv6';
|
||||
import { embeddedTransitionIpv4, expandIpv6 } from './ipv6';
|
||||
|
||||
// Frozen at import on purpose (legacy timing; tests reload the module to change it).
|
||||
const ALLOW_INTERNAL_NETWORK = readEnv().net.allowInternalNetwork;
|
||||
@@ -13,6 +13,20 @@ export interface SsrfResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The IPv4 an IPv4-mapped address (`::ffff:a.b.c.d`) stands for, or null.
|
||||
*
|
||||
* Taken from the hextets rather than from the text, because `::ffff:127.0.0.1`
|
||||
* and `::ffff:7f00:1` are one address in two spellings and `dns.lookup` picks
|
||||
* which one comes back. The prefix regexes this replaces covered the dotted
|
||||
* spelling of two ranges; every other mapped address walked past them.
|
||||
*/
|
||||
function mappedIpv4(hextets: number[]): string | null {
|
||||
const g = hextets;
|
||||
if (g[0] || g[1] || g[2] || g[3] || g[4] || g[5] !== 0xffff) return null;
|
||||
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
||||
}
|
||||
|
||||
// Always blocked — no override possible
|
||||
function isAlwaysBlocked(ip: string): boolean {
|
||||
// Strip IPv6 brackets
|
||||
@@ -23,9 +37,21 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
// Unspecified
|
||||
if (addr.startsWith('0.')) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
if (addr.startsWith('169.254.')) return true;
|
||||
|
||||
const hextets = expandIpv6(addr);
|
||||
if (hextets) {
|
||||
// The IPv6 unspecified address. Connecting to it lands on loopback, so it
|
||||
// reaches a local service without naming one, and it has enough spellings
|
||||
// (`::`, `::0`, `0:0:0:0:0:0:0:0`) that only the expanded hextets settle it.
|
||||
// The `0.` check above never saw any of them: they begin with a colon.
|
||||
if (hextets.every(h => h === 0)) return true;
|
||||
// fe80::/10 spans fe80: through febf:, not just the four characters 'fe80'.
|
||||
if ((hextets[0] & 0xffc0) === 0xfe80) return true;
|
||||
// A mapped address inherits the verdict of the IPv4 it carries.
|
||||
const mapped = mappedIpv4(hextets);
|
||||
if (mapped) return isAlwaysBlocked(mapped);
|
||||
}
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isAlwaysBlocked(embedded);
|
||||
@@ -45,10 +71,12 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
if (/^f[cd]/i.test(addr)) return true;
|
||||
// IPv4-mapped RFC-1918
|
||||
if (/^::ffff:10\./i.test(addr)) return true;
|
||||
if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true;
|
||||
if (/^::ffff:192\.168\./i.test(addr)) return true;
|
||||
// A mapped address inherits the verdict of the IPv4 it carries. Deciding on the
|
||||
// hextets rather than on three `::ffff:`-prefix regexes also covers the hex
|
||||
// spelling and the CGNAT range those regexes never listed.
|
||||
const hextets = expandIpv6(addr);
|
||||
const mapped = hextets && mappedIpv4(hextets);
|
||||
if (mapped) return isPrivateNetwork(mapped);
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isPrivateNetwork(embedded);
|
||||
|
||||
@@ -70,6 +70,7 @@ let checkPermission: MockInstance;
|
||||
|
||||
import { CollabModule } from '../../src/nest/collab/collab.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
|
||||
|
||||
describe('Collab e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
@@ -168,6 +169,55 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(db.prepare('SELECT COUNT(*) as c FROM collab_message_reactions WHERE message_id = 3').get()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
// The advisory this route was reported under: it answered anyone with a session,
|
||||
// for any trip id, and drove an outbound fetch from that.
|
||||
it('404 on link-preview for a trip the caller cannot reach', async () => {
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server)
|
||||
.get('/api/trips/5/collab/link-preview?url=https://example.com/')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('401 on link-preview without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/trips/5/collab/link-preview?url=https://example.com/');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
// A read-only member still gets previews: the route is requested while rendering
|
||||
// the chat, so gating it on the write permission would blank the chat for them.
|
||||
it('200 on link-preview for a member without collab_edit', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true, headers: { get: () => null }, text: async () => '<title>Lesbar</title>',
|
||||
}));
|
||||
const res = await request(server)
|
||||
.get('/api/trips/5/collab/link-preview?url=https://example.com/reader')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
vi.unstubAllGlobals();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Lesbar');
|
||||
});
|
||||
|
||||
it('429 once the caller has spent a minute of preview fetches', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true, headers: { get: () => null }, text: async () => '<title>T</title>',
|
||||
}));
|
||||
// Distinct URLs, because a repeat is served from the cache and costs nothing.
|
||||
let last = 200;
|
||||
for (let i = 0; i < 61 && last === 200; i++) {
|
||||
last = (await request(server)
|
||||
.get(`/api/trips/5/collab/link-preview?url=${encodeURIComponent(`https://example.com/e2e-${i}`)}`)
|
||||
.set('Cookie', sessionCookie(1))).status;
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
expect(last).toBe(429);
|
||||
// The counters live on the container singleton, so a spent budget would
|
||||
// follow this user into every test declared after it.
|
||||
app.get(RateLimitService).reset('collab_link_preview');
|
||||
});
|
||||
|
||||
it('400 on link-preview without a url', async () => {
|
||||
const res = await request(server).get('/api/trips/5/collab/link-preview').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CategoriesMcp } from '../../src/nest/categories/categories.mcp';
|
||||
import { CategoriesService } from '../../src/nest/categories/categories.service';
|
||||
import { CollabMcp } from '../../src/nest/collab/collab.mcp';
|
||||
import { CollabService } from '../../src/nest/collab/collab.service';
|
||||
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
|
||||
import { CollectionsMcp } from '../../src/nest/collections/collections.mcp';
|
||||
import { CollectionsService } from '../../src/nest/collections/collections.service';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
@@ -111,7 +112,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService);
|
||||
const todoService = new TodoService(dbService, permissionsService, realtimeService);
|
||||
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
|
||||
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage);
|
||||
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService());
|
||||
// Exactly one instance, shared by maps, places and share: its stampede guard
|
||||
// and its on-disk set only work if all three readers see the same maps.
|
||||
const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver'
|
||||
import { SettingsService } from '../../src/nest/settings/settings.service';
|
||||
import { FilesService } from '../../src/nest/files/files.service';
|
||||
import { CollabService } from '../../src/nest/collab/collab.service';
|
||||
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
|
||||
import { VacayService } from '../../src/nest/vacay/vacay.service';
|
||||
import { TripsService } from '../../src/nest/trips/trips.service';
|
||||
import { PlacesService } from '../../src/nest/places/places.service';
|
||||
@@ -94,7 +95,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
|
||||
const packing = new PackingService(dbs, permissions, realtime, notificationsStub());
|
||||
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage);
|
||||
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs));
|
||||
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage);
|
||||
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService());
|
||||
const vacay = new VacayService(dbs, realtime, notificationsStub());
|
||||
const days = new DaysService(dbs, permissions, realtime, queryHelpers);
|
||||
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage);
|
||||
|
||||
@@ -58,6 +58,9 @@ import { CollabService } from '../../src/nest/collab/collab.service';
|
||||
|
||||
// Spy on the DI-native service's linkPreview so the SSRF-guarded fetch never
|
||||
// runs; the rest of CollabService exercises its real SQL through the container.
|
||||
// The original is kept so the guard cases below can put it back for one call —
|
||||
// mockRestore would drop the spy for every test declared after them.
|
||||
const realLinkPreview = CollabService.prototype.linkPreview;
|
||||
const linkPreviewSpy = vi.spyOn(CollabService.prototype, 'linkPreview')
|
||||
.mockResolvedValue({ title: null, description: null, image: null, url: '' });
|
||||
|
||||
@@ -808,6 +811,31 @@ describe('Link preview', () => {
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
// Every case above mocks linkPreview out, so the SSRF guard, the redirect
|
||||
// refusal and the pinned dispatcher are unproven over HTTP: swapping
|
||||
// checkSsrf(url, true) for checkSsrf(url) would leave this file green. These
|
||||
// two put the real implementation back for one call each. No traffic leaves
|
||||
// the machine — the guard refuses both targets before any fetch.
|
||||
it.each([
|
||||
['loopback', 'http://127.0.0.1:9/'],
|
||||
// The unspecified address routes to loopback when connected to, and used to
|
||||
// pass the guard: it is not '::1', and it does not start with '0.'.
|
||||
['the unspecified address', 'http://[::]:9/'],
|
||||
])('COLLAB-032 — GET /collab/link-preview refuses %s (%s) through the real guard', async (_label, url) => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
linkPreviewSpy.mockImplementationOnce(realLinkPreview);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=${encodeURIComponent(url)}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
// One constant reason. The guard knows three, and telling them apart would
|
||||
// map out the internal DNS of the server one guessed hostname at a time.
|
||||
expect(res.body).toEqual({ error: 'URL not allowed' });
|
||||
});
|
||||
|
||||
it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -5,6 +5,8 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { CollabController } from '../../../src/nest/collab/collab.controller';
|
||||
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import type { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import type { User } from '../../../src/types';
|
||||
@@ -181,6 +183,32 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
});
|
||||
});
|
||||
|
||||
// The decorators, not the handler body. Constructing the controller directly,
|
||||
// which every test above does, runs no guard at all — so removing the trip check
|
||||
// again would leave this file green. It shipped without one once.
|
||||
describe('link preview guard chain', () => {
|
||||
const guardsOn = (target: object): unknown[] => (Reflect.getMetadata('__guards__', target) as unknown[]) ?? [];
|
||||
|
||||
it('resolves the trip and refuses a caller who cannot reach it', () => {
|
||||
expect(guardsOn(CollabController.prototype.linkPreview)).toContain(TripAccessGuard);
|
||||
});
|
||||
|
||||
it('sits behind the same authentication as the rest of the controller', () => {
|
||||
expect(guardsOn(CollabController)).toContain(JwtAuthGuard);
|
||||
});
|
||||
|
||||
it('demands no write permission, matching the other read routes', () => {
|
||||
// It is requested while rendering a message or a note, never while writing
|
||||
// one. Requiring collab_edit would strip previews from a trip whose owner
|
||||
// narrowed that right, for people who may still read the chat.
|
||||
for (const handler of [CollabController.prototype.linkPreview, CollabController.prototype.listMessages, CollabController.prototype.listNotes]) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
||||
}
|
||||
// The write siblings do demand it, so this is a deliberate split, not an omission.
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, CollabController.prototype.createMessage)).toBe('collab_edit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link preview', () => {
|
||||
it('400 without url, maps an error result to 400, else returns the preview', async () => {
|
||||
expect(await thrownAsync(() => new CollabController(svc(), storageStub).linkPreview(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
@@ -189,6 +217,17 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({ title: 'T', description: null, image: null, url: 'http://x' });
|
||||
});
|
||||
|
||||
it('maps an exhausted preview budget to 429, not to the 400 a refused URL gets', async () => {
|
||||
const s = svc({ linkPreview: vi.fn().mockResolvedValue({ title: null, description: null, image: null, url: 'http://x', rateLimited: true }) } as Partial<CollabService>);
|
||||
expect(await thrownAsync(() => new CollabController(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({ status: 429, body: { error: 'Too many requests' } });
|
||||
});
|
||||
|
||||
it('passes the caller through, so the budget is charged per user and not per instance', async () => {
|
||||
const linkPreview = vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
|
||||
await new CollabController(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(user, '5', 'http://x');
|
||||
expect(linkPreview).toHaveBeenCalledWith('http://x', user.id);
|
||||
});
|
||||
|
||||
it('falls back to a null preview when the service throws', async () => {
|
||||
const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({ title: null, description: null, image: null, url: 'http://x' });
|
||||
|
||||
@@ -68,9 +68,14 @@ import { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||||
|
||||
const collabFx = makeStorageFixture('files/');
|
||||
const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage);
|
||||
const rateLimit = new RateLimitService();
|
||||
const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, rateLimit);
|
||||
|
||||
/** A CollabService with its own preview cache and budget, for the tests that fill either. */
|
||||
const freshSvc = () => new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService());
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -488,6 +493,169 @@ describe('linkPreview', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── linkPreview hardening ───────────────────────────────────────────────
|
||||
|
||||
describe('linkPreview hardening', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** A fetch stub that records what it was called with. */
|
||||
const stubFetch = (response: Record<string, unknown>) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ headers: { get: () => null }, ...response });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
};
|
||||
|
||||
it('COLLAB-SVC-039: asks the SSRF guard to refuse internal targets even where the instance allows them', async () => {
|
||||
stubFetch({ ok: true, text: async () => '<html/>' });
|
||||
await freshSvc().linkPreview('https://example.com/guard-args');
|
||||
// The second argument is what keeps ALLOW_INTERNAL_NETWORK from widening a
|
||||
// route whose URL comes from whoever is typing in the chat.
|
||||
expect(mockCheckSsrf).toHaveBeenCalledWith('https://example.com/guard-args', true);
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-040: pins the connection to the checked IP and refuses to follow redirects', async () => {
|
||||
const dispatcher = { close: vi.fn().mockResolvedValue(undefined) };
|
||||
mockCreatePinnedDispatcher.mockReturnValueOnce(dispatcher);
|
||||
const fetchMock = stubFetch({ ok: true, text: async () => '<html/>' });
|
||||
|
||||
await freshSvc().linkPreview('https://example.com/init');
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
// Without redirect:'error' a public URL could 302 onto an internal one, and
|
||||
// the pin does not cover that hop: Node skips the pinned lookup for a literal IP.
|
||||
expect(init.redirect).toBe('error');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(mockCreatePinnedDispatcher).toHaveBeenCalledWith('93.184.216.34');
|
||||
expect(init.dispatcher).toBe(dispatcher);
|
||||
// One Agent is built per preview; leaving it open leaks its sockets.
|
||||
expect(dispatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-041: a refused URL comes back with one constant reason, never the one the guard gave', async () => {
|
||||
// The guard distinguishes "could not resolve", "private address" and
|
||||
// "loopback". Relaying that verbatim turns the route into a probe for the
|
||||
// internal DNS of the server, one guessed hostname at a time.
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: false, isPrivate: true, resolvedIp: '10.0.0.5', error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.' });
|
||||
const result = await freshSvc().linkPreview('http://nas.internal/');
|
||||
expect(result.error).toBe('URL not allowed');
|
||||
expect(JSON.stringify(result)).not.toContain('ALLOW_INTERNAL_NETWORK');
|
||||
expect(JSON.stringify(result)).not.toContain('private');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-042: a document built to make the scrape backtrack still returns promptly', async () => {
|
||||
// '<meta ' with no '>' anywhere used to make each unbounded [^>]* rescan the
|
||||
// rest of the document from every '<meta' it passed: quadratic, and the byte
|
||||
// cap admits half a megabyte of it. At the 240KB used here the unbounded form
|
||||
// measured ~58s; bounded it is well under a second, so the budget below is not
|
||||
// a close call under CI load. Node is single-threaded, so that is the
|
||||
// whole server, on one request, from any trip member.
|
||||
stubFetch({ ok: true, text: async () => '<meta '.repeat(40_000) });
|
||||
const started = Date.now();
|
||||
const result = await freshSvc().linkPreview('https://example.com/redos');
|
||||
expect(Date.now() - started).toBeLessThan(5_000);
|
||||
expect(result.title).toBeNull();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-043: a body that is not markup is dropped instead of scraped', async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
stubFetch({
|
||||
ok: true,
|
||||
headers: { get: (h: string) => (h === 'content-type' ? 'video/mp4' : null) },
|
||||
body: { getReader: () => ({ read: async () => ({ done: true }), cancel }), cancel },
|
||||
text: async () => '<title>Nicht gelesen</title>',
|
||||
});
|
||||
const result = await freshSvc().linkPreview('https://example.com/video');
|
||||
expect(result.title).toBeNull();
|
||||
// An unread body keeps its socket reserved until the collector runs.
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-044: an og:image the client could not load safely is dropped', async () => {
|
||||
// The client puts this straight into an <img src>. A previewed page must not
|
||||
// be able to aim that at a scheme, or at a host, of its choosing.
|
||||
for (const [image, expected] of [
|
||||
['javascript:alert(1)', null],
|
||||
['data:image/png;base64,AAAA', null],
|
||||
['file:///etc/passwd', null],
|
||||
['//evil.example/x.png', null],
|
||||
['https://cdn.example/ok.png', 'https://cdn.example/ok.png'],
|
||||
['http://cdn.example/ok.png', 'http://cdn.example/ok.png'],
|
||||
] as const) {
|
||||
stubFetch({ ok: true, text: async () => `<meta property="og:image" content="${image}">` });
|
||||
const result = await freshSvc().linkPreview(`https://example.com/img-${encodeURIComponent(image)}`);
|
||||
expect(result.image).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-045: a preview already fetched is served again without a second request', async () => {
|
||||
const fetchMock = stubFetch({ ok: true, text: async () => '<title>Einmal geholt</title>' });
|
||||
const service = freshSvc();
|
||||
const first = await service.linkPreview('https://example.com/cached', 7);
|
||||
const second = await service.linkPreview('https://example.com/cached', 7);
|
||||
expect(first.title).toBe('Einmal geholt');
|
||||
expect(second.title).toBe('Einmal geholt');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-046: the budget runs out per user, and cached previews do not charge it', async () => {
|
||||
const fetchMock = stubFetch({ ok: true, text: async () => '<title>T</title>' });
|
||||
const service = freshSvc();
|
||||
|
||||
// 60 distinct URLs is the whole allowance for a minute.
|
||||
for (let i = 0; i < 60; i++) {
|
||||
expect((await service.linkPreview(`https://example.com/p${i}`, 42)).rateLimited).toBeUndefined();
|
||||
}
|
||||
expect((await service.linkPreview('https://example.com/p60', 42)).rateLimited).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(60);
|
||||
|
||||
// The client re-requests every preview it renders on each mount, so a reload
|
||||
// has to stay free: otherwise the fix is the regression.
|
||||
expect((await service.linkPreview('https://example.com/p0', 42)).rateLimited).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(60);
|
||||
|
||||
// Another user has their own allowance.
|
||||
expect((await service.linkPreview('https://example.com/p60', 43)).rateLimited).toBeUndefined();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-048: simultaneous askers for one URL share a single outbound fetch', async () => {
|
||||
// The client renders a preview per message and deduplicates nothing, so the
|
||||
// same link posted twenty times arrives as twenty requests at once — none of
|
||||
// which finds a cache entry, because the first has not answered yet.
|
||||
let release: (() => void) | undefined;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const fetchMock = vi.fn().mockImplementation(async () => {
|
||||
await gate;
|
||||
return { ok: true, headers: { get: () => null }, text: async () => '<title>Geteilt</title>' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const service = freshSvc();
|
||||
const all = Promise.all(Array.from({ length: 20 }, () => service.linkPreview('https://example.com/same', 9)));
|
||||
release!();
|
||||
const results = await all;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(results.every(r => r.title === 'Geteilt')).toBe(true);
|
||||
// And the nineteen that joined were not charged for a fetch they did not make.
|
||||
expect(results.some(r => r.rateLimited)).toBe(false);
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-047: a body over the cap, and one behind a failed response, are both released', async () => {
|
||||
for (const response of [
|
||||
{ ok: true, headers: { get: (h: string) => (h === 'content-length' ? String(10 * 1024 * 1024) : null) } },
|
||||
{ ok: false },
|
||||
]) {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
stubFetch({ ...response, body: { getReader: () => ({ read: async () => ({ done: true }), cancel }), cancel }, text: async () => '' });
|
||||
const result = await freshSvc().linkPreview(`https://example.com/drop-${String(response.ok)}`);
|
||||
expect(result.title).toBeNull();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// COLLAB-SVC-031..033 (collab.bridge delegation) were deleted with the bridge —
|
||||
// its last consumers (the legacy tripService and the legacy get_trip_summary
|
||||
// registrar) migrated into the DI-native TripsService/TripsMcp.
|
||||
@@ -498,7 +666,7 @@ describe('hardening', () => {
|
||||
it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', () => {
|
||||
const { user1, trip } = setup();
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), collabFx.storage);
|
||||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService());
|
||||
const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
failing.votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
|
||||
@@ -519,7 +687,7 @@ describe('hardening', () => {
|
||||
it('COLLAB-SVC-035: deleteNote is atomic — trip_files rows survive a failed note DELETE', async () => {
|
||||
const { user1, trip } = setup();
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), collabFx.storage);
|
||||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService());
|
||||
const note = failing.createNote(trip.id, user1.id, { title: 'With file' });
|
||||
testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)')
|
||||
.run(trip.id, note.id, 'files/a.pdf', 'a.pdf');
|
||||
@@ -540,7 +708,7 @@ describe('hardening', () => {
|
||||
const { user1, trip } = setup();
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const failingStorage = { delete: vi.fn().mockRejectedValue(new Error('EACCES')) };
|
||||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), failingStorage as unknown as import('../../../src/nest/storage/storage.service').StorageService);
|
||||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), failingStorage as unknown as import('../../../src/nest/storage/storage.service').StorageService, new RateLimitService());
|
||||
const note = failing.createNote(trip.id, user1.id, { title: 'Sticky file' });
|
||||
testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)')
|
||||
.run(trip.id, note.id, 'stuck.pdf', 'stuck.pdf');
|
||||
|
||||
@@ -60,6 +60,7 @@ import { ReservationsReadRepository } from '../../../src/nest/reservations/reser
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||||
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
|
||||
@@ -100,7 +101,7 @@ const buildReadModel = (database: DatabaseService, roster: TripMembersService =
|
||||
database, roster, daysSvc, accommodationsSvc, budgetSvc,
|
||||
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
|
||||
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs())),
|
||||
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage),
|
||||
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService()),
|
||||
placesSvc,
|
||||
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
|
||||
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService(), makeStorageFixture('').storage),
|
||||
|
||||
@@ -66,6 +66,7 @@ import { ReservationsReadRepository } from '../../../src/nest/reservations/reser
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||||
import { VacayService } from '../../../src/nest/vacay/vacay.service';
|
||||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
@@ -130,7 +131,7 @@ const readModelSvc = new TripReadModelService(
|
||||
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
|
||||
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
|
||||
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs())),
|
||||
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), coversFx.storage),
|
||||
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService()),
|
||||
placesSvc,
|
||||
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
|
||||
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService(), coversFx.storage),
|
||||
|
||||
@@ -179,6 +179,76 @@ describe('checkSsrf', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// SEC-013 — the unspecified address and IPv4-mapped spellings
|
||||
describe('unspecified address and IPv4-mapped spellings (always blocked)', () => {
|
||||
// Connecting to the unspecified address lands on loopback, so `::` reaches a
|
||||
// local service without ever naming one. It has several spellings and none of
|
||||
// them start with '0.', which is all the IPv4 check ever looked for.
|
||||
it.each([
|
||||
['bare', '::'],
|
||||
['single zero', '::0'],
|
||||
['fully written out', '0:0:0:0:0:0:0:0'],
|
||||
['zero-padded', '0000:0000:0000:0000:0000:0000:0000:0000'],
|
||||
])('SEC-013: blocks the unspecified address, %s (%s)', async (_label, ip) => {
|
||||
mockIp(ip);
|
||||
const result = await checkSsrf('http://attacker.example', true);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
// An IPv4-mapped address is the IPv4 it carries. Matching '::ffff:127.' and
|
||||
// '::ffff:169.254.' as text missed both the hex spelling of those same
|
||||
// addresses and every other blocked range.
|
||||
it.each([
|
||||
['mapped loopback, dotted', '::ffff:127.0.0.1'],
|
||||
['mapped loopback, hex', '::ffff:7f00:1'],
|
||||
['mapped metadata, dotted', '::ffff:169.254.169.254'],
|
||||
['mapped metadata, hex', '::ffff:a9fe:a9fe'],
|
||||
['mapped unspecified', '::ffff:0:0'],
|
||||
['mapped 0.0.0.0, dotted', '::ffff:0.0.0.0'],
|
||||
])('SEC-013: blocks %s (%s)', async (_label, ip) => {
|
||||
mockIp(ip);
|
||||
const result = await checkSsrf('http://attacker.example', true);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mapped RFC-1918, hex', '::ffff:c0a8:1'],
|
||||
['mapped CGNAT', '::ffff:100.64.0.1'],
|
||||
])('SEC-013: treats %s (%s) as private', async (_label, ip) => {
|
||||
mockIp(ip);
|
||||
const result = await checkSsrf('http://attacker.example');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it('SEC-013: still allows a mapped public address', async () => {
|
||||
mockIp('::ffff:8.8.8.8');
|
||||
const result = await checkSsrf('http://cdn.example');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.isPrivate).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// SEC-014 — fe80::/10 is ten bits, not the four characters 'fe80'
|
||||
describe('the whole IPv6 link-local range', () => {
|
||||
it.each([['fe80::1'], ['fe90::1'], ['fea0::1'], ['febf::1']])(
|
||||
'SEC-014: blocks %s',
|
||||
async (ip) => {
|
||||
mockIp(ip);
|
||||
const result = await checkSsrf('http://attacker.example', true);
|
||||
expect(result.allowed).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// The bounds, so widening fe80: to the full /10 cannot creep further: fe7f
|
||||
// sits just below the range and fec0 just above it, and neither is link-local.
|
||||
it.each([['fe7f::1'], ['fec0::1']])('SEC-014: %s is outside fe80::/10', async (ip) => {
|
||||
mockIp(ip);
|
||||
const result = await checkSsrf('http://cdn.example', true);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('internal hostname suffixes', () => {
|
||||
it('blocks .local domains', async () => {
|
||||
const result = await checkSsrf('http://myserver.local');
|
||||
|
||||
@@ -38,6 +38,8 @@ Hover a message to reveal the action buttons. Click **Reply** to quote that mess
|
||||
|
||||
When a message contains a URL, TREK automatically fetches an Open Graph preview (title, description, and thumbnail image) and displays it below the message text. Only the first URL in a message generates a preview.
|
||||
|
||||
The fetch runs on the server, so it is kept on a short leash: only trip members can request one, the target must be a public HTTP(S) address (addresses on your own network are refused), a preview is remembered for ten minutes, and a member can trigger sixty new fetches a minute. A preview that is refused or runs past the budget is simply not shown — the message itself is unaffected.
|
||||
|
||||
## Message styling
|
||||
|
||||
Your own messages appear **right-aligned** with a blue bubble. Other members' messages appear **left-aligned** with a gray bubble. The username is shown above the first message in a group of consecutive messages from the same person; the avatar is shown beside the **last** message in that group. Timestamps appear below the last message in each group.
|
||||
|
||||
Reference in New Issue
Block a user