v2.0.8: Production-Haertung Notes-API (Auth + CORS)
CI / test (push) Canceled after 0s
CI / webapp (push) Canceled after 0s

- src/notes-api-server.ts: NotesApiOptions erweitert
  - authToken: HMAC-SHA256 Bearer-Token (timing-safe compare)
  - allowedOrigins: CORS-Whitelist (oder '*' fuer alle)
- checkAuth(authHeader): pruft Bearer-Token
- generateToken(secret): statische Methode fuer Client-Tools
- checkOrigin(origin): CORS-Pruefung
- corsHeader(origin): liefert CORS-Header (leer wenn nicht erlaubt)
- Token-Format: <timestamp>.<hmac-hex>

Tests:
- tests/auth-cors.test.ts: 14 neue Tests
  - Auth: ohne Token, Header fehlt, falsches Schema, falsches Format
  - Auth: korrekter Token, falsches Secret, HMAC-Tampering
  - CORS: keine Config, Wildcard *, Whitelist
  - CORS: corsHeader mit/ohne Erlaubnis
  - generateToken: Format, verschiedene Secrets

Verifiziert:
- 116/116 Tests gruen (14 neue)
- Auth nutzt timingSafeEqual (kein String-Compare)
- NotesApi bleibt abwaertskompatibel (alte Aufrufe ohne Options funktionieren)
This commit is contained in:
Jannik committed 2026-09-05 12:42:05 +02:00
1 parent 2f0768f590
commit 9d70cc615e
2 files changed
+170 -2

No files matched your search

+63 -2
View File
@@ -1,7 +1,8 @@
// Notes-API-Server: Logik fuer die Vite-Dev-Server-Middleware
// Phase 4.3 — isoliert testbar
// Phase 4.3 — isoliert testbar, mit Auth + CORS (Phase 9)
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { join } from 'node:path';
import matter from 'gray-matter';
import { spawn } from 'node:child_process';
@@ -18,8 +19,68 @@ export interface SyncResult {
message: string;
}
export interface AuthCheck {
ok: boolean;
reason?: string;
}
export interface NotesApiOptions {
authToken?: string; // wenn gesetzt, ist Auth noetig
allowedOrigins?: string[]; // CORS-Whitelist
}
export class NotesApi {
constructor(private readonly vaultPath: string) {}
constructor(
private readonly vaultPath: string,
private readonly options: NotesApiOptions = {},
) {}
/** Prueft einen Auth-Token (HMAC-SHA256). */
checkAuth(authHeader: string | undefined): AuthCheck {
if (!this.options.authToken) return { ok: true };
if (!authHeader) return { ok: false, reason: 'Authorization-Header fehlt' };
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || !token) {
return { ok: false, reason: 'Authorization-Schema ungueltig (erwartet: Bearer)' };
}
// Token-Format: <timestamp>.<hmac>
const [ts, hmac] = token.split('.');
if (!ts || !hmac) return { ok: false, reason: 'Token-Format ungueltig' };
const expected = createHmac('sha256', this.options.authToken)
.update(ts)
.digest('hex');
if (expected.length !== hmac.length) return { ok: false, reason: 'Token ungueltig' };
const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(hmac));
return ok ? { ok: true } : { ok: false, reason: 'Token ungueltig' };
}
/** Generiert einen Token (fuer Client-Tools). */
static generateToken(secret: string): string {
const ts = Date.now().toString();
const hmac = createHmac('sha256', secret).update(ts).digest('hex');
return `${ts}.${hmac}`;
}
/** Prueft, ob Origin erlaubt ist. */
checkOrigin(origin: string | undefined): boolean {
if (!this.options.allowedOrigins || this.options.allowedOrigins.length === 0) {
return true; // keine Beschraenkung
}
if (!origin) return false;
return this.options.allowedOrigins.includes(origin) || this.options.allowedOrigins.includes('*');
}
/** Setzt CORS-Header, wenn Origin erlaubt ist. */
corsHeader(origin: string | undefined): Record<string, string> {
if (this.checkOrigin(origin)) {
return {
'Access-Control-Allow-Origin': origin ?? '*',
'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
};
}
return {};
}
listNotes(): string[] {
const result: string[] = [];
+107
View File
@@ -0,0 +1,107 @@
// Tests fuer Auth + CORS
// Phase 9: Production-Haertung
import { describe, it, expect } from 'vitest';
import { NotesApi } from '../src/notes-api-server.js';
describe('NotesApi Auth', () => {
it('kein Token konfiguriert: alle Requests erlaubt', () => {
const api = new NotesApi('/tmp/vault');
expect(api.checkAuth(undefined).ok).toBe(true);
expect(api.checkAuth('Bearer irgendwas').ok).toBe(true);
});
it('Token konfiguriert, aber Header fehlt: 401', () => {
const api = new NotesApi('/tmp/vault', { authToken: 'secret' });
const result = api.checkAuth(undefined);
expect(result.ok).toBe(false);
expect(result.reason).toContain('fehlt');
});
it('Token mit falschem Schema: ungueltig', () => {
const api = new NotesApi('/tmp/vault', { authToken: 'secret' });
expect(api.checkAuth('Basic abc').ok).toBe(false);
});
it('Token mit falschem Format: ungueltig', () => {
const api = new NotesApi('/tmp/vault', { authToken: 'secret' });
expect(api.checkAuth('Bearer not-a-valid-token').ok).toBe(false);
});
it('Korrekter Token: erlaubt', () => {
const secret = 'my-secret';
const api = new NotesApi('/tmp/vault', { authToken: secret });
const token = NotesApi.generateToken(secret);
const result = api.checkAuth(`Bearer ${token}`);
expect(result.ok).toBe(true);
});
it('Token mit falschem Secret: ungueltig', () => {
const api = new NotesApi('/tmp/vault', { authToken: 'correct-secret' });
const wrongToken = NotesApi.generateToken('wrong-secret');
expect(api.checkAuth(`Bearer ${wrongToken}`).ok).toBe(false);
});
it('HMAC ist timing-safe (kein einfacher String-Vergleich)', () => {
// Wir koennen das nicht direkt testen, aber die Implementation nutzt timingSafeEqual
const api = new NotesApi('/tmp/vault', { authToken: 'secret' });
const token = NotesApi.generateToken('secret');
// Aendere ein Zeichen im Token
const modified = token.slice(0, -2) + (token.endsWith('0') ? '1' : '0') + token.slice(-1);
expect(api.checkAuth(`Bearer ${modified}`).ok).toBe(false);
});
});
describe('NotesApi CORS', () => {
it('keine Origins konfiguriert: alle erlaubt', () => {
const api = new NotesApi('/tmp/vault');
expect(api.checkOrigin('https://evil.com')).toBe(true);
expect(api.checkOrigin(undefined)).toBe(true);
});
it('Wildcard * erlaubt alle Origins', () => {
const api = new NotesApi('/tmp/vault', { allowedOrigins: ['*'] });
expect(api.checkOrigin('https://anywhere.com')).toBe(true);
expect(api.checkOrigin('https://evil.com')).toBe(true);
});
it('Whitelist: nur konfigurierte Origins', () => {
const api = new NotesApi('/tmp/vault', {
allowedOrigins: ['https://vault.example.com', 'https://app.example.com'],
});
expect(api.checkOrigin('https://vault.example.com')).toBe(true);
expect(api.checkOrigin('https://app.example.com')).toBe(true);
expect(api.checkOrigin('https://evil.com')).toBe(false);
expect(api.checkOrigin(undefined)).toBe(false);
});
it('corsHeader liefert korrekte Header fuer erlaubte Origins', () => {
const api = new NotesApi('/tmp/vault', { allowedOrigins: ['https://app.example.com'] });
const headers = api.corsHeader('https://app.example.com');
expect(headers['Access-Control-Allow-Origin']).toBe('https://app.example.com');
expect(headers['Access-Control-Allow-Methods']).toContain('GET');
expect(headers['Access-Control-Allow-Methods']).toContain('PUT');
});
it('corsHeader liefert leere Header fuer nicht erlaubte Origins', () => {
const api = new NotesApi('/tmp/vault', { allowedOrigins: ['https://app.example.com'] });
expect(api.corsHeader('https://evil.com')).toEqual({});
});
});
describe('NotesApi.generateToken', () => {
it('Token-Format: <timestamp>.<hmac>', () => {
const token = NotesApi.generateToken('secret');
expect(token).toMatch(/^\d+\.[a-f0-9]+$/);
});
it('Verschiedene Secrets ergeben verschiedene Tokens', () => {
const t1 = NotesApi.generateToken('secret-a');
const t2 = NotesApi.generateToken('secret-b');
expect(t1).not.toBe(t2);
// Aber gleicher Timestamp-Teil moeglich
const hmac1 = t1.split('.')[1];
const hmac2 = t2.split('.')[1];
expect(hmac1).not.toBe(hmac2);
});
});