#191: feeds.enabled instance switch + feed-token log masking #242

Merged
fable-5 merged 1 commits from feat/191-feeds-switch into main 2026-07-30 11:49:41 +02:00
13 changed files with 133 additions and 9 deletions
Showing only changes of commit afef45732a - Show all commits

View File

@ -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) }),
},
},
}),
}),

View File

@ -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();
});
});

View File

@ -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<T extends string | undefined>(url: T): T {
return url?.replace(/([?&]token=)[^&#]*/gi, '$1[redacted]') as T;
}

View File

@ -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<void> {
if (!(await this.settings.get('feeds.enabled'))) throw new NotFoundException();
}
@Get()
list(@Req() request: AuthedRequest): Promise<FeedTokenView[]> {
async list(@Req() request: AuthedRequest): Promise<FeedTokenView[]> {
await this.assertFeedsEnabled();
return this.tokens.list(request.user!);
}
@Post()
create(
async create(
@Body(new ZodValidationPipe(createFeedTokenInputSchema)) input: CreateFeedTokenInput,
@Req() request: AuthedRequest,
): Promise<FeedTokenCreatedView> {
await this.assertFeedsEnabled();
return this.tokens.create(request.user!, input);
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.assertFeedsEnabled();
await this.tokens.remove(request.user!, id);
}
}

View File

@ -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);
}
});
});

View File

@ -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<void> {
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<string> {
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<string> {
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');

View File

@ -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],
})

View File

@ -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

View File

@ -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')}
</label>
<p className="api-opt-in__hint">{t('admin.mcpHint')}</p>
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings['feeds.enabled']}
onChange={(event) => void save({ 'feeds.enabled': event.target.checked })}
/>
{t('admin.feedsLabel')}
</label>
<p className="api-opt-in__hint">{t('admin.feedsHint')}</p>
</section>
);
}

View File

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

View File

@ -95,7 +95,7 @@ chain`_
durchgelassen · 1 AT · #189
- [x] **Session-Timeout konfigurierbar**, Default deutlich unter 30 Tagen,
separates Idle-Timeout · 12 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

View File

@ -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",

View File

@ -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",