v2.0.8: Production-Haertung Notes-API (Auth + CORS)
- 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:
1 parent
2f0768f590
commit
9d70cc615e
2 files changed
+170
-2
No files matched your search
+63
-2
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user