From afef45732a49a761e3937f657f5a400ac210ae72 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 30 Jul 2026 11:34:43 +0200 Subject: [PATCH] #191: feeds.enabled instance switch, feed-token log masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chosen path: an instance master switch following the api.enabled/ mcp.enabled pattern — while off, both feed routes AND the feed-token management answer 404 (existence hidden). Default ON: feeds predate the switch, existing instances and their subscribed readers keep working; the VS-NfD reference configuration (#227) turns it off. Admin UI gets the toggle next to the API/MCP switches (i18n de+en). Moving the token out of the query string is documented as rejected: a path segment lands in the same proxy and request logs, and feed readers cannot send headers — that is why the credential is in the URL at all. What DID leak was our own request log (pino logs req.url): the req serializer now masks ?token= values (common/mask-token-param.ts), so no code path logs the credential. Refs #191 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- apps/api/src/app.module.ts | 6 ++++ apps/api/src/common/mask-token-param.test.ts | 25 ++++++++++++++++ apps/api/src/common/mask-token-param.ts | 9 ++++++ apps/api/src/public/feed-tokens.controller.ts | 30 ++++++++++++++++--- apps/api/src/public/feed.e2e.db.test.ts | 23 ++++++++++++++ apps/api/src/public/public.controller.ts | 11 ++++++- apps/api/src/public/public.module.ts | 3 +- .../src/settings/instance-settings.service.ts | 6 ++++ apps/web/src/pages/AdminSettingsPage.tsx | 10 +++++++ docs/architecture/security.md | 9 ++++++ docs/vs-nfd/20-massnahmenplan.md | 2 +- packages/shared/i18n/de/apiTokens.json | 4 ++- packages/shared/i18n/en/apiTokens.json | 4 ++- 13 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/common/mask-token-param.test.ts create mode 100644 apps/api/src/common/mask-token-param.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index adbeb98..d9c8486 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; import { BackupModule } from './backup/backup.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; +import { maskTokenParam } from './common/mask-token-param'; import { CommentsModule } from './comments/comments.module'; import { CompactionModule } from './compaction/compaction.module'; import { AppConfig } from './config/app-config.service'; @@ -91,6 +92,11 @@ import { VersionsModule } from './versions/versions.module'; autoLogging: config.env.NODE_ENV !== 'test', // Request bodies are never logged (operations.md logging rules). redact: { paths: ['req.headers.authorization', 'req.headers.cookie'], remove: true }, + // Feed tokens travel as `?token=` (issue #191) — mask them so the + // request log never stores the credential. + serializers: { + req: (req: { url?: string }) => ({ ...req, url: maskTokenParam(req.url) }), + }, }, }), }), diff --git a/apps/api/src/common/mask-token-param.test.ts b/apps/api/src/common/mask-token-param.test.ts new file mode 100644 index 0000000..e6e1b67 --- /dev/null +++ b/apps/api/src/common/mask-token-param.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { maskTokenParam } from './mask-token-param'; + +describe('maskTokenParam (issue #191)', () => { + it('masks a token as the only query parameter', () => { + expect(maskTokenParam('/api/v1/public/p/feed.xml?token=dt_feed_abc123')).toBe( + '/api/v1/public/p/feed.xml?token=[redacted]', + ); + }); + + it('masks a token between other parameters and stops at delimiters', () => { + expect(maskTokenParam('/x?a=1&token=secret&b=2')).toBe('/x?a=1&token=[redacted]&b=2'); + expect(maskTokenParam('/x?token=secret#frag')).toBe('/x?token=[redacted]#frag'); + }); + + it('leaves URLs without a token parameter untouched', () => { + expect(maskTokenParam('/api/v1/ponds?filter=token')).toBe('/api/v1/ponds?filter=token'); + expect(maskTokenParam('/api/v1/readyz')).toBe('/api/v1/readyz'); + }); + + it('passes undefined through', () => { + expect(maskTokenParam(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/api/src/common/mask-token-param.ts b/apps/api/src/common/mask-token-param.ts new file mode 100644 index 0000000..3f4b623 --- /dev/null +++ b/apps/api/src/common/mask-token-param.ts @@ -0,0 +1,9 @@ +/** + * Masks credential-bearing `token` query parameters before a URL reaches + * the request log (issue #191): feed tokens travel in the query string + * because feed readers cannot send headers, and the api's own log must + * not become the place where that long-lived credential is stored. + */ +export function maskTokenParam(url: T): T { + return url?.replace(/([?&]token=)[^&#]*/gi, '$1[redacted]') as T; +} diff --git a/apps/api/src/public/feed-tokens.controller.ts b/apps/api/src/public/feed-tokens.controller.ts index c78d478..65352a8 100644 --- a/apps/api/src/public/feed-tokens.controller.ts +++ b/apps/api/src/public/feed-tokens.controller.ts @@ -1,4 +1,14 @@ -import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + NotFoundException, + Param, + Post, + Req, +} from '@nestjs/common'; import { createFeedTokenInputSchema, type CreateFeedTokenInput, @@ -9,6 +19,7 @@ import { import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { FeedTokensService } from './feed-tokens.service'; /** @@ -18,24 +29,35 @@ import { FeedTokensService } from './feed-tokens.service'; @Controller('users/me/feed-tokens') @AuthenticatedOnly() export class FeedTokensController { - constructor(private readonly tokens: FeedTokensService) {} + constructor( + private readonly tokens: FeedTokensService, + private readonly settings: InstanceSettingsService, + ) {} + + /** Feed master switch (issue #191): disabled ⇒ the surface answers 404. */ + private async assertFeedsEnabled(): Promise { + if (!(await this.settings.get('feeds.enabled'))) throw new NotFoundException(); + } @Get() - list(@Req() request: AuthedRequest): Promise { + async list(@Req() request: AuthedRequest): Promise { + await this.assertFeedsEnabled(); return this.tokens.list(request.user!); } @Post() - create( + async create( @Body(new ZodValidationPipe(createFeedTokenInputSchema)) input: CreateFeedTokenInput, @Req() request: AuthedRequest, ): Promise { + await this.assertFeedsEnabled(); return this.tokens.create(request.user!, input); } @Delete(':id') @HttpCode(204) async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.assertFeedsEnabled(); await this.tokens.remove(request.user!, id); } } diff --git a/apps/api/src/public/feed.e2e.db.test.ts b/apps/api/src/public/feed.e2e.db.test.ts index 6c41517..cf563ae 100644 --- a/apps/api/src/public/feed.e2e.db.test.ts +++ b/apps/api/src/public/feed.e2e.db.test.ts @@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; @@ -119,6 +120,7 @@ describe.skipIf(!hasTestDb)('atom feeds (e2e, issue #149)', () => { }); afterAll(async () => { + await prisma.instanceSetting.deleteMany({ where: { key: 'feeds.enabled' } }); await prisma.feedToken.deleteMany({ where: { userId: ownerId } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } }); await prisma.pageVersion.deleteMany({ where: { page: { pond: { ownerId } } } }); @@ -195,4 +197,25 @@ describe.skipIf(!hasTestDb)('atom feeds (e2e, issue #149)', () => { expect(res.text).toContain('rel="alternate" type="application/atom+xml"'); expect(res.text).toContain(`/api/v1/public/${pondSlug}/feed.xml`); }); + + it('answers 404 on the whole feed surface while feeds.enabled is off (issue #191)', async () => { + const settings = app.get(InstanceSettingsService); + await settings.set('feeds.enabled', false, ownerId); + try { + // Feed routes hide — even for a pond that serves anonymously above. + await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(404); + await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`).expect(404); + // The token management surface hides with them. + await api().get('/api/v1/users/me/feed-tokens').set('Cookie', ownerCookie).expect(404); + await api() + .post('/api/v1/users/me/feed-tokens') + .set('Cookie', ownerCookie) + .send({ name: 'nope' }) + .expect(404); + // The rest of the public surface is untouched. + await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}`).expect(200); + } finally { + await settings.set('feeds.enabled', true, ownerId); + } + }); }); diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index 95822a7..b722b33 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Param, Query, Req, Res } from '@nestjs/common'; import type { PageCommentsView } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest, Public } from '../auth/auth.guard'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { FeedService } from './feed.service'; import { PublicPageContent, PublicService } from './public.service'; @@ -17,8 +18,14 @@ export class PublicController { constructor( private readonly publicPages: PublicService, private readonly feeds: FeedService, + private readonly settings: InstanceSettingsService, ) {} + /** Feed master switch (issue #191): disabled ⇒ 404, existence hidden. */ + private async assertFeedsEnabled(): Promise { + if (!(await this.settings.get('feeds.enabled'))) throw new NotFoundException(); + } + // The feed routes come FIRST: `:pondSlug/feed.xml` would otherwise be // swallowed by the `:pondSlug/:pageSlug` HTML route below (issue #149). @Get(':pondSlug/feed.xml') @@ -29,6 +36,7 @@ export class PublicController { @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { + await this.assertFeedsEnabled(); const viewer = await this.feeds.viewerFor(request.user ?? null, token); const xml = await this.feeds.pondFeed(viewer, pondSlug, baseUrlOf(request)); response.set('Content-Type', 'application/atom+xml; charset=utf-8'); @@ -44,6 +52,7 @@ export class PublicController { @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { + await this.assertFeedsEnabled(); const viewer = await this.feeds.viewerFor(request.user ?? null, token); const xml = await this.feeds.pageFeed(viewer, pondSlug, pageSlug, baseUrlOf(request)); response.set('Content-Type', 'application/atom+xml; charset=utf-8'); diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts index d40cc7d..2a98b0e 100644 --- a/apps/api/src/public/public.module.ts +++ b/apps/api/src/public/public.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { CommentsModule } from '../comments/comments.module'; import { PagesModule } from '../pages/pages.module'; import { PluginsModule } from '../plugins/plugins.module'; +import { SettingsModule } from '../settings/settings.module'; import { FeedTokensController } from './feed-tokens.controller'; import { FeedTokensService } from './feed-tokens.service'; @@ -18,7 +19,7 @@ import { ReadContentController } from './read-content.controller'; * marks `GET /media/:fileId` public too. */ @Module({ - imports: [PluginsModule, CommentsModule, PagesModule], + imports: [PluginsModule, CommentsModule, PagesModule, SettingsModule], controllers: [PublicController, ReadContentController, FeedTokensController], providers: [PublicService, FeedService, FeedTokensService], }) diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index fae7e8b..cd890c3 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -56,6 +56,12 @@ export const INSTANCE_SETTINGS = { // Built-in MCP endpoint master switch (issue #105, default off) — // independent of the REST switch; ponds opt in via `mcpEnabled`. 'mcp.enabled': z.boolean().default(false), + // Atom feed master switch (issue #191). Default ON: feeds predate the + // switch, so existing instances and their subscribed readers keep + // working; the VS-NfD reference configuration (#227) turns it off. + // While disabled, the feed routes AND the feed-token management answer + // 404 (existence hidden, same semantics as the two switches above). + 'feeds.enabled': z.boolean().default(true), // Backup targets (ADR 0015, issue #103). The backup sidecar reads these // rows directly (apps/backup settings.ts — keep the schemas in sync); the // Nextcloud app password is NOT here, it lives in the secret store diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 021ba5f..13ff59d 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -24,6 +24,7 @@ interface InstanceSettings { 'quota.maxFileBytes': number; 'api.enabled': boolean; 'mcp.enabled': boolean; + 'feeds.enabled': boolean; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; 'legal.imprint': string; @@ -239,6 +240,15 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re {t('admin.mcpLabel')}

{t('admin.mcpHint')}

+ +

{t('admin.feedsHint')}

); } diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 4c5aa7f..d180c11 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -31,6 +31,15 @@ or sloppy plugin authors, compromised dependencies. password reset via single-use hashed tokens; both rate-limited. - Rate limiting (DB-backed) on login, signup, reset, and API; lockout backoff on repeated failed logins per account+IP. +- Feed tokens (issue #149) authenticate feed URLs via `?token=` — feed + readers cannot send headers, which is why the credential lives in the + URL at all. Moving it into a path segment was rejected (issue #191): a + path lands in the same proxy and request logs as a query string. + Instead: the instance switch `feeds.enabled` hides the whole feed + surface with 404 semantics (the VS-NfD reference configuration turns + feeds off), tokens are stored hashed, and the api's request log masks + `?token=` values (`common/mask-token-param.ts`), so no code path logs + the credential. - Self-registration can be disabled instance-wide; personal-pond quotas (editors/readers/ponds/storage) bound the blast radius of spam accounts. diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index e51e9b7..3629ffb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -95,7 +95,7 @@ chain`_ durchgelassen · 1 AT · #189 - [x] **Session-Timeout konfigurierbar**, Default deutlich unter 30 Tagen, separates Idle-Timeout · 1–2 AT · #190 -- [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart +- [x] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart abschaltbar · 2 AT · #191 - [ ] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy vollständig deaktivierbar · 2 AT · #192 diff --git a/packages/shared/i18n/de/apiTokens.json b/packages/shared/i18n/de/apiTokens.json index 4111133..fd58e4f 100644 --- a/packages/shared/i18n/de/apiTokens.json +++ b/packages/shared/i18n/de/apiTokens.json @@ -50,7 +50,9 @@ "save": "Speichern", "saved": "Gespeichert.", "mcpLabel": "Eingebauten MCP-Endpoint aktivieren", - "mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md." + "mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md.", + "feedsLabel": "Atom-Feeds aktivieren", + "feedsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Feed-Adressen und die Feed-Token-Verwaltung mit 404 — für gehärtete Umgebungen, in denen Feed-Tokens als Lese-Zugangsdaten nicht in URLs auftauchen dürfen." }, "feed": { "title": "Feed-Tokens", diff --git a/packages/shared/i18n/en/apiTokens.json b/packages/shared/i18n/en/apiTokens.json index e5ca2f5..7af1b71 100644 --- a/packages/shared/i18n/en/apiTokens.json +++ b/packages/shared/i18n/en/apiTokens.json @@ -50,7 +50,9 @@ "save": "Save", "saved": "Saved.", "mcpLabel": "Enable the built-in MCP endpoint", - "mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md." + "mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md.", + "feedsLabel": "Enable Atom feeds", + "feedsHint": "Master switch (default on). While off, every feed URL and the feed-token management answer 404 — for hardened environments where feed tokens must not appear in URLs as read credentials." }, "feed": { "title": "Feed tokens", -- 2.45.2