feat: implement security audit fixes SEC-001 through SEC-031

This commit is contained in:
neon
2026-04-12 16:15:03 -06:00
parent 1f43ea2cb5
commit 67ec10f703
104 changed files with 17954 additions and 336 deletions
+1 -10
View File
@@ -6,16 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Projectx</title>
<!-- Blocking theme init — must run before first paint to prevent FOUC -->
<script>
;(function () {
var theme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
var accent = localStorage.getItem('accent') || 'neutral'
var radius = localStorage.getItem('radius') || 'default'
if (theme === 'dark') document.documentElement.classList.add('dark')
if (accent !== 'neutral') document.documentElement.classList.add('accent-' + accent)
if (radius !== 'default') document.documentElement.classList.add('radius-' + radius)
})()
</script>
<script src="/theme-init.js"></script>
</head>
<body>
<div id="app"></div>
+2
View File
@@ -31,6 +31,7 @@
"@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"howler": "^2.2.4",
"lucide-vue-next": "^1.0.0",
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/dompurify": "^3.2.0",
"@types/jsdom": "^28.0.1",
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.5",
+8
View File
@@ -0,0 +1,8 @@
;(function () {
var theme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
var accent = localStorage.getItem('accent') || 'neutral'
var radius = localStorage.getItem('radius') || 'default'
if (theme === 'dark') document.documentElement.classList.add('dark')
if (accent !== 'neutral') document.documentElement.classList.add('accent-' + accent)
if (radius !== 'default') document.documentElement.classList.add('radius-' + radius)
})()
@@ -15,6 +15,7 @@ import { getFormatColor } from '../lib/format-colors'
import { FORMAT_TO_GROUP } from '@projectx/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
const props = defineProps<{ bookId: number | null; open: boolean }>()
const { hasPermission } = usePermissions()
@@ -131,6 +132,7 @@ const providerLinks = computed<ProviderLink[]>(() => {
const descriptionExpanded = ref(false)
const genresExpanded = ref(false)
const coverLightboxOpen = ref(false)
const safeDescription = useSafeHtml(() => detail.value?.description)
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
@@ -373,7 +375,7 @@ function toggleGenres() {
<div
class="text-sm leading-relaxed text-foreground/80 transition-all"
:class="descriptionExpanded ? '' : 'line-clamp-4'"
v-html="detail.description"
v-html="safeDescription"
/>
<button
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
@@ -25,6 +25,7 @@ import AddToCollectionSheet from '@/features/collection/components/AddToCollecti
import MetadataScoreBadge from '@/features/metadata-score/components/MetadataScoreBadge.vue'
import MetadataScoreBreakdown from '@/features/metadata-score/components/MetadataScoreBreakdown.vue'
import { useMetadataScoreWeights } from '@/features/metadata-score/composables/useMetadataScoreWeights'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
type FileProgress = {
percentage: number
@@ -86,6 +87,7 @@ const genresExpanded = ref(false)
const genreMeasureContainer = ref<HTMLElement | null>(null)
const genreHiddenCount = ref(0)
const visibleGenreCount = ref(0)
const safeDescription = useSafeHtml(() => props.book.description)
const displayedGenres = computed(() => {
if (genresExpanded.value || genreHiddenCount.value === 0) return props.book.genres
const count = visibleGenreCount.value > 0 ? visibleGenreCount.value : props.book.genres.length
@@ -1354,7 +1356,7 @@ watch(
<div
class="text-sm leading-relaxed text-foreground/80 transition-all"
:class="descriptionExpanded ? '' : 'line-clamp-2'"
v-html="book.description"
v-html="safeDescription"
/>
<button
class="text-xs text-muted-foreground hover:text-foreground mt-2 transition-colors"
@@ -0,0 +1,71 @@
import { ref } from 'vue'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSafeHtml } from './useSafeHtml'
vi.mock('dompurify', () => ({
default: {
sanitize: vi.fn((input: string, config: { ALLOWED_TAGS: string[] }) => {
if (!config?.ALLOWED_TAGS) return ''
// Simulate DOMPurify by stripping disallowed tags and dangerous attributes
return input
.replace(/<script\b[^>]*>.*?<\/script>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/javascript:[^"']*/gi, '')
}),
},
}))
import DOMPurify from 'dompurify'
const sanitizeMock = vi.mocked(DOMPurify.sanitize)
describe('useSafeHtml', () => {
beforeEach(() => {
sanitizeMock.mockImplementation((input: string | Node) => String(input))
})
it('returns empty string when content is null', () => {
const html = useSafeHtml(() => null)
expect(html.value).toBe('')
})
it('returns empty string when content is undefined', () => {
const html = useSafeHtml(() => undefined)
expect(html.value).toBe('')
})
it('returns empty string when content is an empty string', () => {
const html = useSafeHtml(() => '')
expect(html.value).toBe('')
})
it('passes content through DOMPurify.sanitize with allowed tags', () => {
const html = useSafeHtml(() => '<b>Hello</b>')
const _ = html.value
expect(sanitizeMock).toHaveBeenCalledWith('<b>Hello</b>', {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a'],
})
})
it('is reactive and updates when the source function returns a new value', () => {
const raw = ref('<em>initial</em>')
const html = useSafeHtml(() => raw.value)
expect(html.value).toBe('<em>initial</em>')
raw.value = '<strong>updated</strong>'
expect(html.value).toBe('<strong>updated</strong>')
})
it('returns sanitized output (strips script tags)', () => {
sanitizeMock.mockRestore()
// Use the real mock behavior from the module-level mock
sanitizeMock.mockImplementation((input: string | Node) => {
const s = String(input)
return s.replace(/<script\b[^>]*>.*?<\/script>/gi, '').replace(/\son\w+="[^"]*"/gi, '')
})
const html = useSafeHtml(() => '<b>Safe</b><script>alert(1)</script>')
expect(html.value).toBe('<b>Safe</b>')
expect(html.value).not.toContain('<script>')
})
})
@@ -0,0 +1,12 @@
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const SAFE_TAGS = ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a']
export function useSafeHtml(rawHtml: () => string | null | undefined) {
return computed(() => {
const content = rawHtml()
if (!content) return ''
return DOMPurify.sanitize(content, { ALLOWED_TAGS: SAFE_TAGS })
})
}
@@ -126,21 +126,21 @@ describe('useFoliateInput', () => {
doc.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 5 } }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 5 }, origin: window.location.origin }))
vi.advanceTimersByTime(300)
expect(prev).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(300)
doc.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 95 } }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 95 }, origin: window.location.origin }))
vi.advanceTimersByTime(300)
expect(next).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(300)
doc.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 50 } }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 50 }, origin: window.location.origin }))
vi.advanceTimersByTime(300)
expect(onMiddleTap).toHaveBeenCalledTimes(1)
@@ -94,7 +94,7 @@ export function useFoliateInput(
iframeWidth: iframeRect.width,
eventClientX: touch.clientX,
},
'*',
window.location.origin,
)
}
}
@@ -141,7 +141,7 @@ export function useFoliateInput(
iframeWidth: rect.width,
eventClientX: e.clientX,
},
'*',
window.location.origin,
)
},
true,
@@ -155,6 +155,7 @@ export function useFoliateInput(
}
function handleWindowMessage(e: MessageEvent) {
if (e.origin !== window.location.origin) return
if (e.data?.type !== 'foliate-click') return
const view = getViewEl()
if (!view) return
@@ -24,13 +24,11 @@ const avatarCardOpen = ref(false)
const isMobile = useMediaQuery('(max-width: 767px)')
const formName = ref('')
const formEmail = ref('')
watch(
() => user.value,
(current) => {
formName.value = current?.name ?? ''
formEmail.value = current?.email ?? ''
},
{ immediate: true },
)
@@ -41,7 +39,7 @@ const profileBusy = computed(() => busy.value || savingProfile.value)
const profileChanged = computed(() => {
const current = user.value
if (!current) return false
return formName.value.trim() !== current.name || formEmail.value.trim() !== (current.email ?? '')
return formName.value.trim() !== current.name
})
const saveFeedback = computed(() => {
if (profileError.value) return profileError.value
@@ -92,13 +90,11 @@ async function saveProfile() {
savingProfile.value = true
try {
const trimmedEmail = formEmail.value.trim()
const res = await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
email: trimmedEmail.length > 0 ? trimmedEmail : null,
}),
})
@@ -120,7 +116,7 @@ async function saveProfile() {
}
}
watch([formName, formEmail], () => {
watch([formName], () => {
profileState.value = 'idle'
if (profileError.value) profileError.value = null
})
@@ -180,11 +176,12 @@ watch(
<div class="space-y-1.5">
<label class="settings-label">Email</label>
<input
v-model="formEmail"
:value="user?.email ?? ''"
type="email"
autocomplete="email"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
readonly
class="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm text-muted-foreground truncate"
/>
<p class="text-xs text-muted-foreground">Contact an administrator to change your email address.</p>
</div>
</div>
+16223
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -48,6 +48,7 @@
"@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18",
"@parcel/watcher": "^2.5.6",
"@projectx/types": "workspace:*",
@@ -65,7 +66,7 @@
"mediainfo.js": "^0.3.7",
"ms": "^2.1.3",
"music-metadata": "^11.12.3",
"mysql2": "^3.21.1",
"mysql2": "^3.22.0",
"nestjs-pino": "^4.6.1",
"node-unrar-js": "^2.0.2",
"nodemailer": "^8.0.5",
@@ -102,7 +103,7 @@
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"globals": "^17.4.0",
"globals": "^17.5.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.2",
"source-map-support": "^0.5.21",
+12
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, storageConfig } from './config/config';
@@ -61,6 +62,16 @@ import { MigrationModule } from './modules/migration/migration.module';
load: [appConfig, dbConfig, authConfig, storageConfig, fileWriteConfig, emailConfig, migrationConfig],
}),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot({
skipIf: () => process.env.NODE_ENV === 'test',
throttlers: [
{
name: 'default',
ttl: 60_000,
limit: 120,
},
],
}),
DbModule,
CommonModule,
SeedModule,
@@ -103,6 +114,7 @@ import { MigrationModule } from './modules/migration/migration.module';
MigrationModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
+245
View File
@@ -0,0 +1,245 @@
import 'reflect-metadata';
import { validate } from 'class-validator';
import { ChangePasswordDto } from '../modules/auth/dto/change-password.dto';
import { LoginDto } from '../modules/auth/dto/login.dto';
import { OidcCallbackDto } from '../modules/auth/dto/oidc-callback.dto';
import { RegisterDto } from '../modules/auth/dto/register.dto';
import { ResetPasswordDto } from '../modules/auth/dto/reset-password.dto';
import { SetupDto } from '../modules/auth/dto/setup.dto';
import { CreateEmailProviderDto } from '../modules/email/dto/create-email-provider.dto';
import { CreateEmailRecipientDto } from '../modules/email/dto/create-email-recipient.dto';
import { CreateEmailRecipientGroupDto } from '../modules/email/dto/create-email-recipient-group.dto';
import { CreateEmailTemplateDto } from '../modules/email/dto/create-email-template.dto';
import { UpdateOidcConfigDto } from '../modules/app-settings/dto/update-oidc-config.dto';
import { UpdateMeDto } from '../modules/user/dto/update-me.dto';
import { CreateUserDto } from '../modules/user/dto/create-user.dto';
import { PrescanLibraryDto } from '../modules/library/dto/prescan-library.dto';
import { SearchBooksDto } from '../modules/book/dto/search-books.dto';
async function hasError(dto: object, property: string, constraint: string): Promise<boolean> {
const errors = await validate(dto);
return errors.some((e) => e.property === property && Object.keys(e.constraints ?? {}).some((k) => k.includes(constraint)));
}
describe('SEC-027 — @MaxLength enforcement on DTO string fields', () => {
describe('LoginDto', () => {
it('rejects username longer than 100 chars', async () => {
const dto = Object.assign(new LoginDto(), { username: 'a'.repeat(101), password: 'secret' });
expect(await hasError(dto, 'username', 'maxLength')).toBe(true);
});
it('rejects password longer than 1024 chars', async () => {
const dto = Object.assign(new LoginDto(), { username: 'user', password: 'x'.repeat(1025) });
expect(await hasError(dto, 'password', 'maxLength')).toBe(true);
});
it('accepts a valid login payload', async () => {
const dto = Object.assign(new LoginDto(), { username: 'user', password: 'secret' });
expect(await validate(dto)).toHaveLength(0);
});
});
describe('ChangePasswordDto', () => {
it('rejects currentPassword longer than 1024 chars', async () => {
const dto = Object.assign(new ChangePasswordDto(), { currentPassword: 'x'.repeat(1025), newPassword: 'Valid1pass' });
expect(await hasError(dto, 'currentPassword', 'maxLength')).toBe(true);
});
it('rejects newPassword longer than 1024 chars', async () => {
const dto = Object.assign(new ChangePasswordDto(), { currentPassword: 'valid', newPassword: 'Aa1' + 'x'.repeat(1022) });
expect(await hasError(dto, 'newPassword', 'maxLength')).toBe(true);
});
});
describe('ResetPasswordDto', () => {
it('rejects token longer than 512 chars', async () => {
const dto = Object.assign(new ResetPasswordDto(), { token: 't'.repeat(513), newPassword: 'Valid1pass' });
expect(await hasError(dto, 'token', 'maxLength')).toBe(true);
});
});
describe('SetupDto', () => {
it('rejects username longer than 100 chars', async () => {
const dto = Object.assign(new SetupDto(), { username: 'a'.repeat(101), name: 'N', email: 'a@b.com', password: 'Pass1word' });
expect(await hasError(dto, 'username', 'maxLength')).toBe(true);
});
it('rejects name longer than 255 chars', async () => {
const dto = Object.assign(new SetupDto(), { username: 'admin', name: 'n'.repeat(256), email: 'a@b.com', password: 'Pass1word' });
expect(await hasError(dto, 'name', 'maxLength')).toBe(true);
});
});
describe('RegisterDto', () => {
it('rejects username longer than 100 chars', async () => {
const dto = Object.assign(new RegisterDto(), { username: 'a'.repeat(101), name: 'N', email: 'a@b.com', password: 'Pass1word' });
expect(await hasError(dto, 'username', 'maxLength')).toBe(true);
});
});
describe('OidcCallbackDto', () => {
it('rejects code longer than 2048 chars', async () => {
const dto = Object.assign(new OidcCallbackDto(), {
code: 'x'.repeat(2049),
codeVerifier: 'v',
redirectUri: 'https://a.com',
nonce: 'n',
state: 's',
});
expect(await hasError(dto, 'code', 'maxLength')).toBe(true);
});
it('rejects state longer than 512 chars', async () => {
const dto = Object.assign(new OidcCallbackDto(), {
code: 'c',
codeVerifier: 'v',
redirectUri: 'https://a.com',
nonce: 'n',
state: 's'.repeat(513),
});
expect(await hasError(dto, 'state', 'maxLength')).toBe(true);
});
});
describe('UpdateOidcConfigDto', () => {
it('rejects providerName longer than 100 chars', async () => {
const dto = Object.assign(new UpdateOidcConfigDto(), { providerName: 'p'.repeat(101) });
expect(await hasError(dto, 'providerName', 'maxLength')).toBe(true);
});
it('rejects issuerUri longer than 2048 chars', async () => {
const dto = Object.assign(new UpdateOidcConfigDto(), { issuerUri: 'https://auth.example.com/' + 'x'.repeat(2025) });
expect(await hasError(dto, 'issuerUri', 'maxLength')).toBe(true);
});
it('rejects clientSecret longer than 1024 chars', async () => {
const dto = Object.assign(new UpdateOidcConfigDto(), { clientSecret: 's'.repeat(1025) });
expect(await hasError(dto, 'clientSecret', 'maxLength')).toBe(true);
});
});
describe('CreateEmailTemplateDto', () => {
it('rejects name longer than 255 chars', async () => {
const dto = Object.assign(new CreateEmailTemplateDto(), { name: 'n'.repeat(256), subject: 'sub', bodyText: 'body' });
expect(await hasError(dto, 'name', 'maxLength')).toBe(true);
});
it('rejects subject longer than 998 chars', async () => {
const dto = Object.assign(new CreateEmailTemplateDto(), { name: 'template', subject: 's'.repeat(999), bodyText: 'body' });
expect(await hasError(dto, 'subject', 'maxLength')).toBe(true);
});
it('accepts valid template data', async () => {
const dto = Object.assign(new CreateEmailTemplateDto(), { name: 'Welcome', subject: 'Hello', bodyText: '<p>Hi</p>' });
expect(await validate(dto)).toHaveLength(0);
});
});
describe('CreateEmailRecipientDto', () => {
it('rejects name longer than 255 chars', async () => {
const dto = Object.assign(new CreateEmailRecipientDto(), { name: 'n'.repeat(256), email: 'a@b.com' });
expect(await hasError(dto, 'name', 'maxLength')).toBe(true);
});
it('accepts a valid recipient', async () => {
const dto = Object.assign(new CreateEmailRecipientDto(), { name: 'Alice', email: 'alice@example.com' });
expect(await validate(dto)).toHaveLength(0);
});
});
describe('CreateEmailRecipientGroupDto', () => {
it('rejects group name longer than 255 chars', async () => {
const dto = Object.assign(new CreateEmailRecipientGroupDto(), { name: 'g'.repeat(256) });
expect(await hasError(dto, 'name', 'maxLength')).toBe(true);
});
});
describe('CreateEmailProviderDto', () => {
it('rejects host longer than 253 chars', async () => {
const dto = Object.assign(new CreateEmailProviderDto(), {
name: 'SMTP',
host: 'h'.repeat(254),
port: 587,
auth: false,
ssl: false,
startTls: false,
});
expect(await hasError(dto, 'host', 'maxLength')).toBe(true);
});
it('rejects password longer than 1024 chars', async () => {
const dto = Object.assign(new CreateEmailProviderDto(), {
name: 'SMTP',
host: 'smtp.example.com',
port: 587,
password: 'p'.repeat(1025),
auth: true,
ssl: false,
startTls: true,
});
expect(await hasError(dto, 'password', 'maxLength')).toBe(true);
});
});
describe('UpdateMeDto', () => {
it('rejects name longer than 255 chars', async () => {
const dto = Object.assign(new UpdateMeDto(), { name: 'n'.repeat(256) });
expect(await hasError(dto, 'name', 'maxLength')).toBe(true);
});
it('accepts a valid name update', async () => {
const dto = Object.assign(new UpdateMeDto(), { name: 'Alice' });
expect(await validate(dto)).toHaveLength(0);
});
});
describe('CreateUserDto', () => {
it('rejects username longer than 100 chars', async () => {
const dto = Object.assign(new CreateUserDto(), { username: 'u'.repeat(101), name: 'Alice', email: 'a@b.com' });
expect(await hasError(dto, 'username', 'maxLength')).toBe(true);
});
it('rejects name longer than 255 chars', async () => {
const dto = Object.assign(new CreateUserDto(), { username: 'alice', name: 'n'.repeat(256), email: 'a@b.com' });
expect(await hasError(dto, 'name', 'maxLength')).toBe(true);
});
});
describe('PrescanLibraryDto', () => {
it('rejects a path longer than 4096 chars', async () => {
const dto = Object.assign(new PrescanLibraryDto(), { paths: ['/' + 'a'.repeat(4096)] });
expect(await hasError(dto, 'paths', 'maxLength')).toBe(true);
});
it('accepts valid short paths', async () => {
const dto = Object.assign(new PrescanLibraryDto(), { paths: ['/books/library'] });
expect(await validate(dto)).toHaveLength(0);
});
});
describe('SearchBooksDto', () => {
it('rejects q longer than 500 chars', async () => {
const dto = Object.assign(new SearchBooksDto(), { q: 'a'.repeat(501) });
expect(await hasError(dto, 'q', 'maxLength')).toBe(true);
});
it('accepts a valid search query', async () => {
const dto = Object.assign(new SearchBooksDto(), { q: 'Dune' });
expect(await validate(dto)).toHaveLength(0);
});
});
});
describe('SEC-030 — UpdateMeDto does not accept email field', () => {
it('UpdateMeDto does not declare an email property', () => {
const dto = new UpdateMeDto();
expect('email' in dto).toBe(false);
});
it('class-validator forbids unknown email field when whitelist+forbidNonWhitelisted are active', async () => {
const { validateOrReject } = await import('class-validator');
const dto = Object.assign(new UpdateMeDto(), { email: 'hacker@evil.com' });
await expect(validateOrReject(dto, { whitelist: true, forbidNonWhitelisted: true })).rejects.toBeDefined();
});
});
+16
View File
@@ -80,4 +80,20 @@ describe('loggerConfig', () => {
});
expect(serializers?.res?.({ statusCode: 204 } as never)).toEqual({ statusCode: 204 });
});
it('SEC-031: redact config is present and covers sensitive fields', async () => {
const config = await loadLoggerConfig('production');
const redact = config.pinoHttp?.redact as { paths: string[]; censor: string } | undefined;
expect(redact).toBeDefined();
expect(redact?.paths).toContain('req.headers.authorization');
expect(redact?.paths).toContain('req.headers.cookie');
expect(redact?.paths).toContain('req.body.password');
expect(redact?.paths).toContain('req.body.currentPassword');
expect(redact?.paths).toContain('req.body.newPassword');
expect(redact?.paths).toContain('req.body.token');
expect(redact?.paths).toContain('req.body.clientSecret');
expect(redact?.paths).toContain('req.body.codeVerifier');
expect(redact?.censor).toBe('[REDACTED]');
});
});
+13
View File
@@ -40,6 +40,19 @@ export const loggerConfig: Params = {
req: (req: IncomingMessage & { id?: string }) => ({ id: req.id, method: req.method, url: req.url }),
res: (res: ServerResponse) => ({ statusCode: res.statusCode }),
},
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
'req.body.token',
'req.body.clientSecret',
'req.body.codeVerifier',
],
censor: '[REDACTED]',
},
...(isDev
? {
transport: {
@@ -0,0 +1,49 @@
import { sanitizeLogValue } from './log-sanitize.utils';
describe('sanitizeLogValue', () => {
it('returns the value unchanged when it has no control characters', () => {
expect(sanitizeLogValue('hello world')).toBe('hello world');
});
it('replaces carriage return with a space', () => {
expect(sanitizeLogValue('line1\rline2')).toBe('line1 line2');
});
it('replaces newline with a space', () => {
expect(sanitizeLogValue('line1\nline2')).toBe('line1 line2');
});
it('replaces tab with a space', () => {
expect(sanitizeLogValue('col1\tcol2')).toBe('col1 col2');
});
it('replaces CRLF log-injection payload characters with spaces', () => {
const payload = 'value\r\nINFO [injected] fake=log';
expect(sanitizeLogValue(payload)).toBe('value INFO [injected] fake=log');
});
it('truncates to default max length of 200', () => {
const long = 'a'.repeat(300);
const result = sanitizeLogValue(long);
expect(result).toHaveLength(200);
});
it('truncates to custom max length', () => {
const result = sanitizeLogValue('abcdefgh', 4);
expect(result).toBe('abcd');
});
it('does not truncate a value at exactly max length', () => {
const exact = 'a'.repeat(200);
expect(sanitizeLogValue(exact)).toBe(exact);
});
it('preserves empty string', () => {
expect(sanitizeLogValue('')).toBe('');
});
it('handles a value shorter than default max length with no changes', () => {
const short = 'short';
expect(sanitizeLogValue(short)).toBe(short);
});
});
@@ -0,0 +1,5 @@
const MAX_LOG_VALUE_LENGTH = 200;
export function sanitizeLogValue(value: string, maxLength = MAX_LOG_VALUE_LENGTH): string {
return value.replace(/[\r\n\t]/g, ' ').slice(0, maxLength);
}
@@ -0,0 +1,86 @@
import { BadRequestException } from '@nestjs/common';
vi.mock('dns/promises', () => ({
lookup: vi.fn(),
}));
import { lookup } from 'dns/promises';
import { ensureSafeUrl, ensureSafeRemoteHost } from './ssrf.utils';
const lookupMock = vi.mocked(lookup);
describe('ensureSafeRemoteHost', () => {
beforeEach(() => vi.resetAllMocks());
it('passes for a public IPv4 address', async () => {
lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
await expect(ensureSafeRemoteHost('example.com')).resolves.toBeUndefined();
});
it('throws BadRequestException for localhost', async () => {
lookupMock.mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as never);
await expect(ensureSafeRemoteHost('localhost')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException for private IPv4 (10.x.x.x)', async () => {
lookupMock.mockResolvedValue([{ address: '10.0.0.1', family: 4 }] as never);
await expect(ensureSafeRemoteHost('internal')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException for private IPv4 (172.16.x.x)', async () => {
lookupMock.mockResolvedValue([{ address: '172.16.0.1', family: 4 }] as never);
await expect(ensureSafeRemoteHost('internal')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException for private IPv4 (192.168.x.x)', async () => {
lookupMock.mockResolvedValue([{ address: '192.168.1.1', family: 4 }] as never);
await expect(ensureSafeRemoteHost('internal')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException for 169.254.x.x (link-local)', async () => {
lookupMock.mockResolvedValue([{ address: '169.254.0.1', family: 4 }] as never);
await expect(ensureSafeRemoteHost('metadata')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException when DNS lookup fails', async () => {
lookupMock.mockRejectedValue(new Error('ENOTFOUND'));
await expect(ensureSafeRemoteHost('unresolvable.invalid')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException when any resolved address is private (even if some are public)', async () => {
lookupMock.mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.1', family: 4 },
] as never);
await expect(ensureSafeRemoteHost('mixed')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException for IPv6 loopback', async () => {
lookupMock.mockResolvedValue([{ address: '::1', family: 6 }] as never);
await expect(ensureSafeRemoteHost('ip6-localhost')).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ensureSafeUrl', () => {
beforeEach(() => vi.resetAllMocks());
it('returns a parsed URL for a safe https URL', async () => {
lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
const result = await ensureSafeUrl('https://example.com/path');
expect(result).toBeInstanceOf(URL);
expect(result.hostname).toBe('example.com');
});
it('throws BadRequestException for an invalid URL', async () => {
await expect(ensureSafeUrl('not-a-url')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException for non-http(s) protocol', async () => {
await expect(ensureSafeUrl('ftp://example.com')).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException when host resolves to private IP', async () => {
lookupMock.mockResolvedValue([{ address: '192.168.1.1', family: 4 }] as never);
await expect(ensureSafeUrl('https://internal.lan')).rejects.toBeInstanceOf(BadRequestException);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { lookup } from 'dns/promises';
import { BadRequestException } from '@nestjs/common';
import { isIP } from 'net';
const SAFE_PROTOCOLS = new Set(['http:', 'https:']);
export async function ensureSafeUrl(rawUrl: string): Promise<URL> {
const candidate = rawUrl.trim();
if (!candidate) throw new BadRequestException('Invalid URL');
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
throw new BadRequestException('Invalid URL');
}
if (!SAFE_PROTOCOLS.has(parsed.protocol)) {
throw new BadRequestException('URL must use http or https');
}
await ensureSafeRemoteHost(parsed.hostname);
return parsed;
}
export async function ensureSafeRemoteHost(hostname: string): Promise<void> {
const normalizedHost = hostname.trim().toLowerCase();
if (!normalizedHost) throw new BadRequestException('URL host is required');
if (normalizedHost === 'localhost' || normalizedHost.endsWith('.localhost') || normalizedHost.endsWith('.local')) {
throw new BadRequestException('URL host is not allowed');
}
// URL.hostname wraps IPv6 in brackets (e.g. [::1]); strip them for isIP/range checks
const bareHost = normalizedHost.startsWith('[') && normalizedHost.endsWith(']') ? normalizedHost.slice(1, -1) : normalizedHost;
const ipFamily = isIP(bareHost);
if (ipFamily > 0) {
if (isPrivateOrLocalAddress(bareHost)) {
throw new BadRequestException('URL host is not allowed');
}
return;
}
let resolved;
try {
resolved = await lookup(normalizedHost, { all: true, verbatim: true });
} catch {
throw new BadRequestException('Unable to resolve URL host');
}
if (resolved.length === 0) throw new BadRequestException('Unable to resolve URL host');
if (resolved.some((entry) => isPrivateOrLocalAddress(entry.address))) {
throw new BadRequestException('URL host is not allowed');
}
}
function isPrivateOrLocalAddress(address: string): boolean {
const normalized = address.toLowerCase();
const mappedV4Prefix = '::ffff:';
const maybeV4 = normalized.startsWith(mappedV4Prefix) ? normalized.slice(mappedV4Prefix.length) : normalized;
const family = isIP(maybeV4);
if (family === 4) {
return isPrivateOrLocalV4(maybeV4);
}
if (family === 6) {
return (
maybeV4 === '::1' ||
maybeV4 === '::' ||
maybeV4.startsWith('fc') ||
maybeV4.startsWith('fd') ||
maybeV4.startsWith('fe8') ||
maybeV4.startsWith('fe9') ||
maybeV4.startsWith('fea') ||
maybeV4.startsWith('feb')
);
}
return true;
}
function isPrivateOrLocalV4(address: string): boolean {
const octets = address.split('.').map((part) => Number(part));
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
return true;
}
if (octets[0] === 10) return true;
if (octets[0] === 127) return true;
if (octets[0] === 0) return true;
if (octets[0] === 169 && octets[1] === 254) return true;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
if (octets[0] === 192 && octets[1] === 168) return true;
if (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127) return true;
if (octets[0] >= 224) return true;
return false;
}
@@ -67,6 +67,8 @@ CREATE TABLE "users" (
"is_superuser" boolean DEFAULT false NOT NULL,
"is_default_password" boolean DEFAULT false NOT NULL,
"token_version" integer DEFAULT 1 NOT NULL,
"failed_login_attempts" integer DEFAULT 0 NOT NULL,
"locked_until" timestamp with time zone,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"oidc_subject" text,
"oidc_issuer" text,
@@ -80,6 +82,7 @@ CREATE TABLE "users" (
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_provisioning_method_chk" CHECK ("users"."provisioning_method" in ('local', 'manual', 'oidc')),
CONSTRAINT "users_token_version_nonnegative_chk" CHECK ("users"."token_version" >= 0),
CONSTRAINT "users_failed_login_attempts_nonnegative_chk" CHECK ("users"."failed_login_attempts" >= 0),
CONSTRAINT "users_avatar_version_nonnegative_chk" CHECK ("users"."avatar_version" >= 0)
);
--> statement-breakpoint
@@ -666,6 +669,7 @@ CREATE TABLE "book_bucket_files" (
"fetched_metadata_sources" jsonb,
"error_message" text,
"metadata_edited_at" timestamp with time zone,
"uploaded_by" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_bucket_files_absolute_path_unique" UNIQUE("absolute_path"),
@@ -868,6 +872,7 @@ ALTER TABLE "kobo_snapshot_books" ADD CONSTRAINT "kobo_snapshot_books_book_id_bo
ALTER TABLE "kobo_sync_settings" ADD CONSTRAINT "kobo_sync_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_bucket_files" ADD CONSTRAINT "book_bucket_files_target_library_id_libraries_id_fk" FOREIGN KEY ("target_library_id") REFERENCES "public"."libraries"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_bucket_files" ADD CONSTRAINT "book_bucket_files_target_folder_id_library_folders_id_fk" FOREIGN KEY ("target_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_bucket_files" ADD CONSTRAINT "book_bucket_files_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
@@ -982,6 +987,7 @@ CREATE UNIQUE INDEX "opds_users_username_lower_uidx" ON "opds_users" USING btree
CREATE INDEX "kobo_devices_user_id_idx" ON "kobo_devices" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "kobo_snapshot_books_snapshot_synced_book_idx" ON "kobo_snapshot_books" USING btree ("snapshot_id","synced","book_id");--> statement-breakpoint
CREATE INDEX "book_bucket_files_status_idx" ON "book_bucket_files" USING btree ("status");--> statement-breakpoint
CREATE INDEX "book_bucket_files_uploaded_by_idx" ON "book_bucket_files" USING btree ("uploaded_by");--> statement-breakpoint
CREATE INDEX "fwl_book_id_written_at_idx" ON "file_write_log" USING btree ("book_id","written_at");--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_one_default_per_user_uidx" ON "email_templates" USING btree ("user_id") WHERE "email_templates"."is_default" = true and "email_templates"."user_id" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;--> statement-breakpoint
@@ -1,5 +1,5 @@
{
"id": "5e7847b4-0c76-43ea-8030-9d4b4e1cb352",
"id": "450f16cf-cf5a-4969-8f2c-7df4b18fd52e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -599,6 +599,19 @@
"notNull": true,
"default": 1
},
"failed_login_attempts": {
"name": "failed_login_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"locked_until": {
"name": "locked_until",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
@@ -740,6 +753,10 @@
"name": "users_token_version_nonnegative_chk",
"value": "\"users\".\"token_version\" >= 0"
},
"users_failed_login_attempts_nonnegative_chk": {
"name": "users_failed_login_attempts_nonnegative_chk",
"value": "\"users\".\"failed_login_attempts\" >= 0"
},
"users_avatar_version_nonnegative_chk": {
"name": "users_avatar_version_nonnegative_chk",
"value": "\"users\".\"avatar_version\" >= 0"
@@ -6105,6 +6122,12 @@
"primaryKey": false,
"notNull": false
},
"uploaded_by": {
"name": "uploaded_by",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -6135,6 +6158,21 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"book_bucket_files_uploaded_by_idx": {
"name": "book_bucket_files_uploaded_by_idx",
"columns": [
{
"expression": "uploaded_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -6155,6 +6193,15 @@
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
},
"book_bucket_files_uploaded_by_users_id_fk": {
"name": "book_bucket_files_uploaded_by_users_id_fk",
"tableFrom": "book_bucket_files",
"tableTo": "users",
"columnsFrom": ["uploaded_by"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1776021950831,
"tag": "0000_baseline",
"when": 1776068167884,
"tag": "0000_dark_master_mold",
"breakpoints": true
}
]
+3
View File
@@ -31,6 +31,8 @@ export const users = pgTable(
isSuperuser: boolean('is_superuser').notNull().default(false),
isDefaultPassword: boolean('is_default_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(1),
failedLoginAttempts: integer('failed_login_attempts').notNull().default(0),
lockedUntil: timestamp('locked_until', { withTimezone: true }),
settings: jsonb('settings').notNull().default({}),
oidcSubject: text('oidc_subject'),
oidcIssuer: text('oidc_issuer'),
@@ -54,6 +56,7 @@ export const users = pgTable(
.where(sql`${t.oidcSubject} is not null and ${t.oidcIssuer} is not null`),
check('users_provisioning_method_chk', sql`${t.provisioningMethod} in ('local', 'manual', 'oidc')`),
check('users_token_version_nonnegative_chk', sql`${t.tokenVersion} >= 0`),
check('users_failed_login_attempts_nonnegative_chk', sql`${t.failedLoginAttempts} >= 0`),
check('users_avatar_version_nonnegative_chk', sql`${t.avatarVersion} >= 0`),
],
);
+3
View File
@@ -3,6 +3,7 @@ import { bigint, check, index, integer, jsonb, pgTable, serial, text, timestamp,
import type { BookBucketMetadata } from '@projectx/types';
import { libraries, libraryFolders } from './libraries';
import { users } from './auth';
export const bookBucketFiles = pgTable(
'book_bucket_files',
@@ -23,6 +24,7 @@ export const bookBucketFiles = pgTable(
fetchedMetadataSources: jsonb('fetched_metadata_sources').$type<Partial<Record<keyof BookBucketMetadata, string>>>(),
errorMessage: text('error_message'),
metadataEditedAt: timestamp('metadata_edited_at', { withTimezone: true }),
uploadedBy: integer('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
@@ -31,6 +33,7 @@ export const bookBucketFiles = pgTable(
},
(t) => [
index('book_bucket_files_status_idx').on(t.status),
index('book_bucket_files_uploaded_by_idx').on(t.uploadedBy),
check('book_bucket_files_status_chk', sql`${t.status} in ('pending', 'extracting', 'fetching', 'ready', 'error')`),
check('book_bucket_files_confidence_range_chk', sql`${t.confidence} is null or (${t.confidence} >= 0 and ${t.confidence} <= 100)`),
],
+1 -1
View File
@@ -83,7 +83,7 @@ async function bootstrap() {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'blob:'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
@@ -2,6 +2,11 @@ import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { DEFAULT_DOWNLOAD_PATTERN, DEFAULT_UPLOAD_PATTERN } from '@projectx/types';
vi.mock('../../common/utils/ssrf.utils', () => ({
ensureSafeUrl: vi.fn().mockImplementation((url: string) => Promise.resolve(new URL(url.replace(/\/$/, '')))),
ensureSafeRemoteHost: vi.fn().mockResolvedValue(undefined),
}));
import { AppSettingsRepository } from './app-settings.repository';
import { AppSettingsService } from './app-settings.service';
@@ -10,6 +10,7 @@ import {
} from '@projectx/types';
import { APP_SETTING_KEYS, DEFAULT_OIDC_CONFIG, type OidcFullConfig } from '../../common/constants/app-settings.constants';
import { ensureSafeUrl } from '../../common/utils/ssrf.utils';
import { AppSettingsRepository } from './app-settings.repository';
const OIDC_TEST_TIMEOUT_MS = 10_000;
@@ -105,13 +106,16 @@ export class AppSettingsService {
throw new BadRequestException('Issuer URI is not configured');
}
const url = `${uri.replace(/\/$/, '')}/.well-known/openid-configuration`;
const parsedIssuer = await ensureSafeUrl(uri);
const normalizedIssuer = parsedIssuer.href.replace(/\/$/, '');
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OIDC_TEST_TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(url, { signal: controller.signal });
const res = await fetch(discoveryUrl, { signal: controller.signal, redirect: 'manual' });
if (!res.ok) {
throw new BadRequestException(`Provider returned HTTP ${res.status}`);
}
@@ -1,17 +1,21 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
class ClaimMappingDto {
@IsString()
@MaxLength(256)
username: string;
@IsString()
@MaxLength(256)
name: string;
@IsString()
@MaxLength(256)
email: string;
@IsString()
@MaxLength(256)
groups: string;
}
@@ -24,6 +28,7 @@ class AutoProvisionDto {
@IsArray()
@IsString({ each: true })
@MaxLength(100, { each: true })
defaultPermissionNames: string[] = [];
}
@@ -34,22 +39,27 @@ export class UpdateOidcConfigDto {
@IsOptional()
@IsString()
@MaxLength(100)
providerName?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
issuerUri?: string;
@IsOptional()
@IsString()
@MaxLength(256)
clientId?: string;
@IsOptional()
@IsString()
@MaxLength(1024)
clientSecret?: string;
@IsOptional()
@IsString()
@MaxLength(500)
scopes?: string;
@IsOptional()
@@ -0,0 +1,104 @@
import 'reflect-metadata';
import { AuthController } from './auth.controller';
function makeController() {
const authService = {
register: vi.fn(),
setupStatus: vi.fn(),
setup: vi.fn(),
login: vi.fn(),
refresh: vi.fn(),
logout: vi.fn(),
buildUserResponse: vi.fn(),
getSessions: vi.fn(),
revokeSession: vi.fn(),
forgotPassword: vi.fn(),
resetPassword: vi.fn(),
changePassword: vi.fn(),
};
const oidcService = {
generateState: vi.fn(),
handleCallback: vi.fn(),
handleBackchannelLogout: vi.fn(),
};
const controller = new AuthController(authService as never, oidcService as never);
return { controller, authService, oidcService };
}
describe('AuthController', () => {
it('delegates auth endpoint methods with request-derived inputs', async () => {
const { controller, authService } = makeController();
const reply = { setCookie: vi.fn() } as never;
const req = { ip: '10.0.0.8', body: { logout_token: 'logout-1' } } as never;
await controller.register({ username: 'jdoe' } as never);
await controller.setupStatus();
await controller.setup({ username: 'owner' } as never, 'setup-token', reply);
await controller.login({ username: 'jdoe', password: 'pass' } as never, req, reply);
await controller.refresh(req, reply);
await controller.logout(req, reply);
controller.me({ id: 1 } as never);
await controller.getSessions({ id: 1 } as never);
await controller.revokeSession({ id: 1 } as never, 5);
await controller.forgotPassword({ email: 'jdoe@example.com' } as never, req);
await controller.resetPassword({ token: 'reset-token', newPassword: 'N3wP@ssword' } as never, req);
await controller.changePassword({ id: 1 } as never, { currentPassword: 'old', newPassword: 'new' } as never, req, reply);
expect(authService.register).toHaveBeenCalledWith({ username: 'jdoe' });
expect(authService.setupStatus).toHaveBeenCalled();
expect(authService.setup).toHaveBeenCalledWith({ username: 'owner' }, 'setup-token', reply);
expect(authService.login).toHaveBeenCalledWith({ username: 'jdoe', password: 'pass' }, reply, '10.0.0.8');
expect(authService.refresh).toHaveBeenCalledWith(req, reply);
expect(authService.logout).toHaveBeenCalledWith(req, reply);
expect(authService.buildUserResponse).toHaveBeenCalledWith({ id: 1 });
expect(authService.getSessions).toHaveBeenCalledWith(1);
expect(authService.revokeSession).toHaveBeenCalledWith(1, 5);
expect(authService.forgotPassword).toHaveBeenCalledWith({ email: 'jdoe@example.com' }, '10.0.0.8');
expect(authService.resetPassword).toHaveBeenCalledWith({ token: 'reset-token', newPassword: 'N3wP@ssword' }, '10.0.0.8');
expect(authService.changePassword).toHaveBeenCalledWith(1, { currentPassword: 'old', newPassword: 'new' }, reply, '10.0.0.8');
});
it('delegates oidc endpoints to OidcService', async () => {
const { controller, oidcService } = makeController();
const reply = {} as never;
oidcService.generateState.mockReturnValue('state-123');
expect(controller.oidcGenerateState()).toEqual({ state: 'state-123' });
await controller.oidcCallback({ code: 'abc' } as never, reply);
await controller.oidcBackchannelLogout({ body: { logout_token: 'logout-1' } } as never);
await controller.oidcBackchannelLogout({ body: undefined } as never);
expect(oidcService.handleCallback).toHaveBeenCalledWith({ code: 'abc' }, reply);
expect(oidcService.handleBackchannelLogout).toHaveBeenNthCalledWith(1, 'logout-1');
expect(oidcService.handleBackchannelLogout).toHaveBeenNthCalledWith(2, '');
});
it('defines throttling metadata for sensitive public endpoints', () => {
const limitKey = 'THROTTLER:LIMITdefault';
const ttlKey = 'THROTTLER:TTLdefault';
const registerLimit = Reflect.getMetadata(limitKey, AuthController.prototype.register) as number;
const registerTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.register) as number;
const setupLimit = Reflect.getMetadata(limitKey, AuthController.prototype.setup) as number;
const setupTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.setup) as number;
const loginLimit = Reflect.getMetadata(limitKey, AuthController.prototype.login) as number;
const loginTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.login) as number;
const forgotLimit = Reflect.getMetadata(limitKey, AuthController.prototype.forgotPassword) as number;
const forgotTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.forgotPassword) as number;
const resetLimit = Reflect.getMetadata(limitKey, AuthController.prototype.resetPassword) as number;
const resetTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.resetPassword) as number;
expect(registerLimit).toBe(3);
expect(registerTtl).toBe(60_000);
expect(setupLimit).toBe(3);
expect(setupTtl).toBe(60_000);
expect(loginLimit).toBe(5);
expect(loginTtl).toBe(60_000);
expect(forgotLimit).toBe(3);
expect(forgotTtl).toBe(3_600_000);
expect(resetLimit).toBe(5);
expect(resetTtl).toBe(60_000);
});
});
@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, Res } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { AuditAction, AuditResource } from '@projectx/types';
@@ -17,6 +18,9 @@ import { ResetPasswordDto } from './dto/reset-password.dto';
import { SetupDto } from './dto/setup.dto';
import { OidcService } from './oidc/oidc.service';
const ONE_MINUTE_MS = 60_000;
const ONE_HOUR_MS = 3_600_000;
@Controller('auth')
export class AuthController {
constructor(
@@ -25,6 +29,7 @@ export class AuthController {
) {}
@Public()
@Throttle({ default: { limit: 3, ttl: ONE_MINUTE_MS } })
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
@@ -37,6 +42,7 @@ export class AuthController {
}
@Public()
@Throttle({ default: { limit: 3, ttl: ONE_MINUTE_MS } })
@Post('setup')
@HttpCode(HttpStatus.CREATED)
setup(@Body() dto: SetupDto, @Headers('x-setup-token') setupToken: string | undefined, @Res({ passthrough: true }) reply: FastifyReply) {
@@ -44,6 +50,7 @@ export class AuthController {
}
@Public()
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
@@ -88,6 +95,7 @@ export class AuthController {
}
@Public()
@Throttle({ default: { limit: 3, ttl: ONE_HOUR_MS } })
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: FastifyRequest) {
@@ -95,6 +103,7 @@ export class AuthController {
}
@Public()
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
@Post('reset-password')
@HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() dto: ResetPasswordDto, @Req() req: FastifyRequest) {
+1 -1
View File
@@ -29,7 +29,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn'), algorithm: 'HS256' },
}),
}),
UserModule,
+148 -4
View File
@@ -176,7 +176,11 @@ describe('AuthService', () => {
((db as unknown as Record<string, unknown>).returning as vi.Mock).mockResolvedValueOnce([]);
await expect(
service.setup({ username: 'admin', name: 'Admin', email: 'admin@example.com', password: 'Admin1234' } as never, undefined, makeReply()),
service.setup(
{ username: 'admin', name: 'Admin', email: 'admin@example.com', password: 'Admin1234' } as never,
'bootstrap-token',
makeReply(),
),
).rejects.toThrow(ConflictException);
});
@@ -201,7 +205,7 @@ describe('AuthService', () => {
const result = await service.setup(
{ username: 'owner', name: 'Owner', email: 'owner@example.com', password: 'Owner1234' } as never,
undefined,
'bootstrap-token',
reply,
);
@@ -275,18 +279,105 @@ describe('AuthService', () => {
const { service, userService } = makeService();
userService.findByUsername.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
passwordHash: '$2b$12$invalidhash',
tokenVersion: 1,
failedLoginAttempts: 0,
lockedUntil: null,
});
await expect(service.login({ username: 'jdoe', password: 'wrongpass' }, makeReply())).rejects.toThrow(UnauthorizedException);
});
it('rejects login attempts while account is locked', async () => {
const { service, db, userService } = makeService();
const lockedUntil = new Date(Date.now() + 60_000);
userService.findByUsername.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
passwordHash: 'mock-hash:pass',
tokenVersion: 1,
failedLoginAttempts: 0,
lockedUntil,
});
await expect(service.login({ username: 'jdoe', password: 'pass' }, makeReply())).rejects.toThrow(UnauthorizedException);
expect(db.update).not.toHaveBeenCalled();
});
it('increments failed login attempts for wrong password and non-locked account', async () => {
const { service, db, userService } = makeService();
userService.findByUsername.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
passwordHash: 'mock-hash:correct',
tokenVersion: 1,
failedLoginAttempts: 2,
lockedUntil: null,
});
await expect(service.login({ username: 'jdoe', password: 'wrong' }, makeReply())).rejects.toThrow(UnauthorizedException);
expect((db as unknown as Record<string, vi.Mock>).set).toHaveBeenCalledWith({ failedLoginAttempts: 3, lockedUntil: null });
});
it('locks account for 15 minutes after five consecutive failed attempts', async () => {
const { service, db, userService } = makeService();
userService.findByUsername.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
passwordHash: 'mock-hash:correct',
tokenVersion: 1,
failedLoginAttempts: 4,
lockedUntil: null,
});
await expect(service.login({ username: 'jdoe', password: 'wrong' }, makeReply())).rejects.toThrow(UnauthorizedException);
const setArg = (db as unknown as Record<string, vi.Mock>).set.mock.calls.at(-1)?.[0] as {
failedLoginAttempts: number;
lockedUntil: Date | null;
};
expect(setArg.failedLoginAttempts).toBe(0);
expect(setArg.lockedUntil).toBeInstanceOf(Date);
expect(setArg.lockedUntil!.getTime()).toBeGreaterThan(Date.now() + 14 * 60_000);
});
it('clears failed login state after successful authentication', async () => {
const { service, db, userService } = makeService();
const reply = makeReply();
userService.findByUsername.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
passwordHash: 'mock-hash:pass',
tokenVersion: 1,
failedLoginAttempts: 3,
lockedUntil: new Date(Date.now() - 5_000),
});
userService.findByIdWithPermissions.mockResolvedValue(makeFullUser());
await service.login({ username: 'jdoe', password: 'pass' }, reply);
expect((db as unknown as Record<string, vi.Mock>).set).toHaveBeenCalledWith({ failedLoginAttempts: 0, lockedUntil: null });
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalled();
});
it('sets auth cookies with automatic Secure mode', async () => {
const { service, userService } = makeService();
const reply = makeReply();
userService.findByUsername.mockResolvedValue({ id: 1, active: true, passwordHash: 'mock-hash:pass', tokenVersion: 1, username: 'jdoe' });
userService.findByUsername.mockResolvedValue({
id: 1,
active: true,
passwordHash: 'mock-hash:pass',
tokenVersion: 1,
username: 'jdoe',
failedLoginAttempts: 0,
lockedUntil: null,
});
userService.findByIdWithPermissions.mockResolvedValue(makeFullUser());
await service.login({ username: 'jdoe', password: 'pass' }, reply);
@@ -294,7 +385,7 @@ describe('AuthService', () => {
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
'access_token',
'signed-jwt',
expect.objectContaining({ path: '/api', secure: 'auto' }),
expect.objectContaining({ path: '/api', secure: 'auto', sameSite: 'strict' }),
);
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
'refresh_token',
@@ -509,6 +600,59 @@ describe('AuthService', () => {
await new Promise((r) => setImmediate(r));
expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled();
});
it('SEC-028: masks email address in logs for unknown email (no user enumeration via logs)', async () => {
const { service, userService } = makeService();
userService.findByEmail.mockResolvedValue(null);
const logSpy = vi.spyOn((service as never as { logger: { log: vi.Mock } }).logger, 'log');
await service.forgotPassword({ email: 'alice@example.com' });
await new Promise((r) => setImmediate(r));
const loggedMessages = logSpy.mock.calls.map((c) => String(c[0]));
expect(loggedMessages.some((m) => m.includes('alice@example.com'))).toBe(false);
expect(loggedMessages.some((m) => m.includes('a***@example.com'))).toBe(true);
});
it('SEC-028: masks email address in logs for inactive account', async () => {
const { service, userService } = makeService();
userService.findByEmail.mockResolvedValue({
id: 1,
email: 'alice@example.com',
name: 'Alice',
active: false,
provisioningMethod: 'local',
username: 'alice',
});
const logSpy = vi.spyOn((service as never as { logger: { log: vi.Mock } }).logger, 'log');
await service.forgotPassword({ email: 'alice@example.com' });
await new Promise((r) => setImmediate(r));
const loggedMessages = logSpy.mock.calls.map((c) => String(c[0]));
expect(loggedMessages.some((m) => m.includes('alice@example.com'))).toBe(false);
expect(loggedMessages.some((m) => m.includes('a***@example.com'))).toBe(true);
});
it('SEC-028: masks email address in logs for OIDC account', async () => {
const { service, userService } = makeService();
userService.findByEmail.mockResolvedValue({
id: 1,
email: 'alice@example.com',
name: 'Alice',
active: true,
provisioningMethod: 'oidc',
username: 'alice',
});
const logSpy = vi.spyOn((service as never as { logger: { log: vi.Mock } }).logger, 'log');
await service.forgotPassword({ email: 'alice@example.com' });
await new Promise((r) => setImmediate(r));
const loggedMessages = logSpy.mock.calls.map((c) => String(c[0]));
expect(loggedMessages.some((m) => m.includes('alice@example.com'))).toBe(false);
expect(loggedMessages.some((m) => m.includes('a***@example.com'))).toBe(true);
});
});
describe('changePassword', () => {
+69 -16
View File
@@ -13,7 +13,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { compare, hash } from 'bcryptjs';
import { createHash, randomBytes } from 'crypto';
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import '@fastify/cookie';
import { and, count, eq, gt, isNull, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -53,6 +53,16 @@ function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
const LOGIN_LOCKOUT_THRESHOLD = 5;
const LOGIN_LOCKOUT_DURATION_MS = 15 * 60_000;
const DUMMY_HASH = '$2a$12$LJ3m4ys3Lk0TSwHBbqP8b.3bFfR1oVDMhPzX8KPrPeuMEJBJJPa.G';
function maskEmail(email: string): string {
const at = email.indexOf('@');
if (at <= 0) return '***';
return `${email[0]}***@${email.slice(at + 1)}`;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
@@ -82,13 +92,13 @@ export class AuthService {
const existingUsername = await tx.query.users.findFirst({
where: eq(sql`lower(${schema.users.username})`, dto.username.toLowerCase()),
});
if (existingUsername) throw new ConflictException('Username already taken');
if (existingUsername) throw new ConflictException('Registration failed');
if (dto.email) {
const existingEmail = await tx.query.users.findFirst({
where: eq(sql`lower(${schema.users.email})`, dto.email.toLowerCase()),
});
if (existingEmail) throw new ConflictException('Email already in use');
if (existingEmail) throw new ConflictException('Registration failed');
}
const [user] = await tx
@@ -178,21 +188,47 @@ export class AuthService {
async login(dto: LoginDto, reply: FastifyReply, ip?: string) {
const user = await this.userService.findByUsername(dto.username);
if (!user || !user.active || !(await compare(dto.password, user.passwordHash))) {
const now = new Date();
if (user?.lockedUntil && user.lockedUntil > now) {
this.logger.warn(
`[auth.login] [fail] username=${dto.username} ip=${ip ?? 'unknown'} errorClass=UnauthorizedException error="invalid credentials" - login failed`,
`[auth.login] [fail] userId=${user.id} username=${dto.username} ip=${ip ?? 'unknown'} errorClass=UnauthorizedException error="account locked" - login failed`,
);
this.auditEvents.emit(AUDIT_EVENT, {
userId: null,
actorUsername: 'system',
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthLoginFailed,
description: `Failed login attempt for username '${dto.username}'`,
ip,
meta: { attemptedUsername: dto.username },
meta: { attemptedUsername: dto.username, lockout: true, lockedUntil: user.lockedUntil.toISOString() },
});
throw new UnauthorizedException('Invalid credentials');
}
const passwordHash = user?.passwordHash ?? DUMMY_HASH;
const isPasswordValid = await compare(dto.password, passwordHash);
if (!user || !user.active || !isPasswordValid) {
const lockedUntil = user && user.active ? await this.recordFailedLoginAttempt(user.id, user.failedLoginAttempts ?? 0, now) : null;
this.logger.warn(
`[auth.login] [fail]${user ? ` userId=${user.id}` : ''} username=${dto.username} ip=${ip ?? 'unknown'} errorClass=UnauthorizedException error="${lockedUntil ? 'account locked' : 'invalid credentials'}" - login failed`,
);
this.auditEvents.emit(AUDIT_EVENT, {
userId: user?.id ?? null,
actorUsername: user?.username ?? 'system',
action: AuditAction.AuthLoginFailed,
description: `Failed login attempt for username '${dto.username}'`,
ip,
meta: lockedUntil
? { attemptedUsername: dto.username, lockout: true, lockedUntil: lockedUntil.toISOString() }
: { attemptedUsername: dto.username },
});
throw new UnauthorizedException('Invalid credentials');
}
if ((user.failedLoginAttempts ?? 0) > 0 || user.lockedUntil) {
await this.db.update(schema.users).set({ failedLoginAttempts: 0, lockedUntil: null }).where(eq(schema.users.id, user.id));
}
const fullUser = await this.userService.findByIdWithPermissions(user.id);
const { accessToken, rawRefreshToken } = await this.issueTokenPair(user.id, user.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
@@ -395,17 +431,17 @@ export class AuthService {
private async processPasswordResetAsync(email: string, ip?: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (!user || !user.email) {
this.logger.log(`Password reset requested for unknown email: ${email}`);
this.logger.log(`Password reset requested for unknown email: ${maskEmail(email)}`);
return;
}
if (!user.active) {
this.logger.log(`Password reset requested for inactive account: ${email}`);
this.logger.log(`Password reset requested for inactive account: ${maskEmail(email)}`);
return;
}
if (user.provisioningMethod === 'oidc') {
this.logger.log(`Password reset requested for OIDC account: ${email}`);
this.logger.log(`Password reset requested for OIDC account: ${maskEmail(email)}`);
return;
}
@@ -518,15 +554,32 @@ export class AuthService {
}
private assertSetupToken(setupToken: string | undefined) {
const isProduction = this.config.get<string>('app.nodeEnv') === 'production';
if (!isProduction) return;
const isDevelopment = this.config.get<string>('app.nodeEnv') === 'development';
if (isDevelopment) return;
const expected = this.config.get<string>('auth.setupBootstrapToken') ?? '';
if (!expected || setupToken !== expected) {
const inputDigest = Buffer.from(sha256(setupToken ?? ''), 'hex');
const expectedDigest = Buffer.from(sha256(expected), 'hex');
if (!timingSafeEqual(inputDigest, expectedDigest)) {
throw new ForbiddenException('Invalid setup token');
}
}
private async recordFailedLoginAttempt(userId: number, currentFailedAttempts: number, now: Date): Promise<Date | null> {
const nextFailedAttempts = currentFailedAttempts + 1;
const lockedUntil = nextFailedAttempts >= LOGIN_LOCKOUT_THRESHOLD ? new Date(now.getTime() + LOGIN_LOCKOUT_DURATION_MS) : null;
await this.db
.update(schema.users)
.set({
failedLoginAttempts: lockedUntil ? 0 : nextFailedAttempts,
lockedUntil,
})
.where(eq(schema.users.id, userId));
return lockedUntil;
}
private setRefreshCookie(reply: FastifyReply, rawToken: string) {
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d') / 1000;
@@ -553,7 +606,7 @@ export class AuthService {
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtExpiresIn') ?? '15m') / 1000;
reply.setCookie('access_token', accessToken, {
httpOnly: true,
sameSite: 'lax',
sameSite: 'strict',
path: '/api',
maxAge: ttlSeconds,
secure: 'auto',
@@ -563,7 +616,7 @@ export class AuthService {
private clearAccessCookie(reply: FastifyReply) {
reply.setCookie('access_token', '', {
httpOnly: true,
sameSite: 'lax',
sameSite: 'strict',
path: '/api',
maxAge: 0,
secure: 'auto',
@@ -1,11 +1,13 @@
import { IsString, Matches, MinLength } from 'class-validator';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@MaxLength(1024)
currentPassword: string;
@IsString()
@MinLength(8)
@MaxLength(1024)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one digit',
})
+3 -1
View File
@@ -1,11 +1,13 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class LoginDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
username: string;
@IsString()
@IsNotEmpty()
@MaxLength(1024)
password: string;
}
@@ -1,23 +1,28 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class OidcCallbackDto {
@IsString()
@IsNotEmpty()
@MaxLength(2048)
code: string;
@IsString()
@IsNotEmpty()
@MaxLength(2048)
codeVerifier: string;
@IsString()
@IsNotEmpty()
@MaxLength(2048)
redirectUri: string;
@IsString()
@IsNotEmpty()
@MaxLength(512)
nonce: string;
@IsString()
@IsNotEmpty()
@MaxLength(512)
state: string;
}
+4 -1
View File
@@ -1,12 +1,14 @@
import { IsEmail, IsString, Matches, MinLength } from 'class-validator';
import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class RegisterDto {
@IsString()
@MinLength(3)
@MaxLength(100)
username: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name: string;
@IsEmail()
@@ -14,6 +16,7 @@ export class RegisterDto {
@IsString()
@MinLength(8)
@MaxLength(1024)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one digit',
})
@@ -1,11 +1,13 @@
import { IsString, Matches, MinLength } from 'class-validator';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class ResetPasswordDto {
@IsString()
@MaxLength(512)
token: string;
@IsString()
@MinLength(8)
@MaxLength(1024)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one digit',
})
+4 -1
View File
@@ -1,12 +1,14 @@
import { IsEmail, IsString, Matches, MinLength } from 'class-validator';
import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class SetupDto {
@IsString()
@MinLength(3)
@MaxLength(100)
username: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name: string;
@IsEmail()
@@ -14,6 +16,7 @@ export class SetupDto {
@IsString()
@MinLength(8)
@MaxLength(1024)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one digit',
})
+30 -3
View File
@@ -10,13 +10,40 @@ function makeStrategy() {
get: vi.fn().mockReturnValue('test-jwt-secret'),
};
// Bypass PassportStrategy super() call
const strategy = Object.create(JwtStrategy.prototype) as JwtStrategy;
(strategy as never as { authService: unknown }).authService = authService;
const strategy = new JwtStrategy(config as never, authService as never);
return { strategy, authService, config };
}
describe('JwtStrategy token extraction', () => {
it('SEC-025: accepts only HS256 algorithm (algorithm pinning)', () => {
const { strategy } = makeStrategy();
const verifyOptions = (strategy as unknown as { _secretOrKey?: unknown; _verifOpts?: { algorithms?: string[] } })._verifOpts;
expect(verifyOptions?.algorithms).toEqual(['HS256']);
});
it('extracts token from Authorization bearer header', () => {
const { strategy } = makeStrategy();
const extractor = (strategy as unknown as { _jwtFromRequest: (req: unknown) => string | null })._jwtFromRequest;
expect(extractor({ headers: { authorization: 'Bearer header-token' } })).toBe('header-token');
});
it('extracts token from access_token cookie', () => {
const { strategy } = makeStrategy();
const extractor = (strategy as unknown as { _jwtFromRequest: (req: unknown) => string | null })._jwtFromRequest;
expect(extractor({ headers: {}, cookies: { access_token: 'cookie-token' } })).toBe('cookie-token');
});
it('does not extract token from query parameters', () => {
const { strategy } = makeStrategy();
const extractor = (strategy as unknown as { _jwtFromRequest: (req: unknown) => string | null })._jwtFromRequest;
expect(extractor({ headers: {}, query: { token: 'query-token' } })).toBeNull();
});
});
describe('JwtStrategy.validate', () => {
it('returns user when validation passes', async () => {
const { strategy, authService } = makeStrategy();
+2 -5
View File
@@ -12,12 +12,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req: any) => req?.cookies?.access_token ?? null,
(req: any) => req?.query?.token ?? null,
]),
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), (req: any) => req?.cookies?.access_token ?? null]),
secretOrKey: config.get<string>('auth.jwtSecret')!,
algorithms: ['HS256'],
});
}
@@ -45,7 +45,7 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token);
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
@@ -241,6 +241,24 @@ describe('AuthorsRepository', () => {
});
});
it.each(['name', 'sortName', 'lastEnrichedAt'] as const)('findPage does not use raw SQL for %s sorting', async (sort) => {
const { db, selectBuilder } = makeDb();
vi.mocked(sql.raw).mockClear();
selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ total: '0' }]);
selectBuilder.offset.mockResolvedValueOnce([]);
const repo = new AuthorsRepository(db as never);
await repo.findPage({
page: 0,
size: 10,
sort,
order: 'desc',
libraryIds: [1],
});
expect(sql.raw).not.toHaveBeenCalled();
});
it('findById returns a summary row when the author is visible in selected libraries', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.limit.mockResolvedValueOnce([
@@ -69,10 +69,12 @@ export class AuthorsRepository {
: params.sort === 'lastAddedAt'
? this.orderByDirection(lastAddedExpr, params.order)
: params.sort === 'lastEnrichedAt'
? sql`${authors.lastEnrichedAt} ${sql.raw(params.order.toUpperCase())} NULLS LAST`
? params.order === 'asc'
? sql`${authors.lastEnrichedAt} ASC NULLS LAST`
: sql`${authors.lastEnrichedAt} DESC NULLS LAST`
: params.sort === 'sortName'
? sql`${sortNameExpr} ${sql.raw(params.order.toUpperCase())}`
: sql`${authors.name} ${sql.raw(params.order.toUpperCase())}`;
? this.orderByDirection(sortNameExpr, params.order)
: this.orderByDirection(authors.name, params.order);
const having = params.minBookCount !== undefined ? sql`count(distinct ${books.id}) >= ${params.minBookCount}` : undefined;
@@ -404,7 +406,7 @@ export class AuthorsRepository {
const clauses: SQL[] = [inArray(books.libraryId, params.libraryIds)];
const query = params.q?.trim();
if (query) {
clauses.push(ilike(authors.name, `%${query}%`));
clauses.push(ilike(authors.name, `%${escapeLikePattern(query)}%`));
}
if (params.hasPhoto !== undefined) {
clauses.push(eq(authors.hasPhoto, params.hasPhoto));
@@ -413,9 +415,19 @@ export class AuthorsRepository {
}
private orderByDirection(
expression: SQL | typeof authors.sortName | typeof bookMetadata.title | typeof bookMetadata.publishedYear | typeof books.addedAt,
expression:
| SQL
| typeof authors.name
| typeof authors.sortName
| typeof bookMetadata.title
| typeof bookMetadata.publishedYear
| typeof books.addedAt,
order: SortDirection,
) {
return order === 'asc' ? asc(expression) : desc(expression);
}
}
function escapeLikePattern(s: string): string {
return s.replace(/[\\%_]/g, '\\$&');
}
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export const AUTHOR_LIST_SORTS = ['name', 'sortName', 'bookCount', 'lastAddedAt', 'lastEnrichedAt'] as const;
export type AuthorListSort = (typeof AUTHOR_LIST_SORTS)[number];
@@ -10,6 +10,7 @@ export type SortDirection = (typeof SORT_DIRECTIONS)[number];
export class ListAuthorsDto {
@IsOptional()
@IsString()
@MaxLength(500)
q?: string;
@IsOptional()
@@ -92,6 +92,8 @@ export class BookBucketFinalizeService implements OnModuleInit {
excludedIds,
status,
search,
userId,
isSuperuser,
});
if (rows.length === 0) break;
@@ -97,6 +97,7 @@ describe('BookBucketIngestService', () => {
fileSize: 1024,
format: 'epub',
status: 'pending',
uploadedBy: null,
});
});
@@ -34,7 +34,7 @@ export class BookBucketIngestService {
this.bookBucketPath = join(appDataPath, 'book-bucket');
}
async ingestUpload(rawFilename: string, fileStream: Readable): Promise<number> {
async ingestUpload(rawFilename: string, fileStream: Readable, uploadedBy?: number): Promise<number> {
const filename = this.validator.sanitizeFilename(rawFilename);
const ext = extname(filename).toLowerCase().slice(1);
@@ -56,6 +56,7 @@ export class BookBucketIngestService {
fileSize: sizeBytes,
format: ext,
status: 'pending',
uploadedBy: uploadedBy ?? null,
});
this.extractMetadataAsync(row.id, destPath, ext);
@@ -49,6 +49,8 @@ function makeController() {
return { controller, service, ingestService, finalizeService, watcherService, repo };
}
const MOCK_USER = { id: 1, isSuperuser: false } as any;
describe('BookBucketController', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -58,7 +60,7 @@ describe('BookBucketController', () => {
const { controller, service } = makeController();
service.listFiles.mockResolvedValue({ items: [], total: 0, page: 1, size: 20 });
await controller.listFiles({});
await controller.listFiles(MOCK_USER, {});
expect(service.listFiles).toHaveBeenCalledWith({
status: undefined,
@@ -67,6 +69,8 @@ describe('BookBucketController', () => {
sort: 'createdAt',
order: 'desc',
search: undefined,
userId: MOCK_USER.id,
isSuperuser: MOCK_USER.isSuperuser,
});
});
@@ -105,7 +109,7 @@ describe('BookBucketController', () => {
file: vi.fn().mockResolvedValue(null),
} as any;
await expect(controller.upload(req)).rejects.toBeInstanceOf(BadRequestException);
await expect(controller.upload(MOCK_USER, req)).rejects.toBeInstanceOf(BadRequestException);
});
it('upload ingests file and returns hydrated row', async () => {
@@ -119,20 +123,26 @@ describe('BookBucketController', () => {
ingestService.ingestUpload.mockResolvedValue(44);
service.getFile.mockResolvedValue({ id: 44, fileName: 'book.epub' });
await expect(controller.upload(req)).resolves.toEqual({ id: 44, fileName: 'book.epub' });
expect(ingestService.ingestUpload).toHaveBeenCalledWith('book.epub', expect.any(Readable));
await expect(controller.upload(MOCK_USER, req)).resolves.toEqual({ id: 44, fileName: 'book.epub' });
expect(ingestService.ingestUpload).toHaveBeenCalledWith('book.epub', expect.any(Readable), MOCK_USER.id);
expect(service.getFile).toHaveBeenCalledWith(44);
});
it('bulk and finalize endpoints delegate payload fields as expected', async () => {
const { controller, service, finalizeService, watcherService } = makeController();
await controller.bulkDiscard({ fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x' });
await controller.applyFetched({ fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x' });
await controller.retryFetch({ fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y' });
await controller.setTarget({ fileIds: [5], selectAll: false, excludedIds: [6], targetLibraryId: undefined, targetFolderId: undefined });
await controller.selectionSummary({ fileIds: [7], selectAll: false, excludedIds: [8] });
await controller.bulkEdit({
await controller.bulkDiscard(MOCK_USER, { fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x' });
await controller.applyFetched(MOCK_USER, { fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x' });
await controller.retryFetch(MOCK_USER, { fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y' });
await controller.setTarget(MOCK_USER, {
fileIds: [5],
selectAll: false,
excludedIds: [6],
targetLibraryId: undefined,
targetFolderId: undefined,
});
await controller.selectionSummary(MOCK_USER, { fileIds: [7], selectAll: false, excludedIds: [8] });
await controller.bulkEdit(MOCK_USER, {
fileIds: [9],
selectAll: false,
excludedIds: [],
@@ -147,7 +157,7 @@ describe('BookBucketController', () => {
);
await controller.rescan();
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined);
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, MOCK_USER.isSuperuser);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, [1], false, [], 2, 3, [], undefined, undefined);
expect(watcherService.rescan).toHaveBeenCalled();
});
@@ -60,7 +60,7 @@ export class BookBucketController {
) {}
@Get('files')
listFiles(@Query() query: ListBookBucketFilesDto) {
listFiles(@CurrentUser() user: RequestUser, @Query() query: ListBookBucketFilesDto) {
return this.service.listFiles({
status: query.status,
page: query.page ?? 1,
@@ -68,17 +68,19 @@ export class BookBucketController {
sort: query.sort ?? 'createdAt',
order: query.order ?? 'desc',
search: query.search,
userId: user.id,
isSuperuser: user.isSuperuser,
});
}
@Get('summary')
getSummary() {
return this.service.getSummary();
getSummary(@CurrentUser() user: RequestUser) {
return this.service.getSummary(user.id, user.isSuperuser);
}
@Get('statistics')
getStatistics() {
return this.service.getStatistics();
getStatistics(@CurrentUser() user: RequestUser) {
return this.service.getStatistics(user.id, user.isSuperuser);
}
@Get('files/:id')
@@ -105,11 +107,11 @@ export class BookBucketController {
@Post('upload')
@HttpCode(HttpStatus.CREATED)
async upload(@Req() req: MultipartRequest) {
async upload(@CurrentUser() user: RequestUser, @Req() req: MultipartRequest) {
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
if (!data) throw new BadRequestException('No file provided');
const fileId = await this.ingestService.ingestUpload(data.filename, data.file as unknown as Readable);
const fileId = await this.ingestService.ingestUpload(data.filename, data.file as unknown as Readable, user.id);
return this.service.getFile(fileId);
}
@@ -126,22 +128,22 @@ export class BookBucketController {
@Post('files/discard')
@HttpCode(HttpStatus.NO_CONTENT)
bulkDiscard(@Body() dto: BulkDiscardDto) {
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search);
bulkDiscard(@CurrentUser() user: RequestUser, @Body() dto: BulkDiscardDto) {
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
}
@Post('files/apply-fetched')
applyFetched(@Body() dto: BulkApplyFetchedDto) {
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search);
applyFetched(@CurrentUser() user: RequestUser, @Body() dto: BulkApplyFetchedDto) {
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
}
@Post('files/retry-fetch')
retryFetch(@Body() dto: BulkRetryFetchDto) {
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search);
retryFetch(@CurrentUser() user: RequestUser, @Body() dto: BulkRetryFetchDto) {
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
}
@Post('files/set-target')
setTarget(@Body() dto: BulkSetTargetDto) {
setTarget(@CurrentUser() user: RequestUser, @Body() dto: BulkSetTargetDto) {
return this.service.bulkSetTarget(
dto.fileIds ?? [],
dto.selectAll,
@@ -150,16 +152,18 @@ export class BookBucketController {
dto.targetFolderId ?? null,
dto.status,
dto.search,
user.id,
user.isSuperuser,
);
}
@Post('files/selection-summary')
selectionSummary(@Body() dto: SelectionSummaryDto) {
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search);
selectionSummary(@CurrentUser() user: RequestUser, @Body() dto: SelectionSummaryDto) {
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
}
@Post('files/bulk-edit')
bulkEdit(@Body() dto: BulkEditBookBucketDto) {
bulkEdit(@CurrentUser() user: RequestUser, @Body() dto: BulkEditBookBucketDto) {
return this.service.bulkEdit(
dto.fileIds,
dto.selectAll,
@@ -169,6 +173,8 @@ export class BookBucketController {
dto.mergeArrays,
dto.status,
dto.search,
user.id,
user.isSuperuser,
);
}
@@ -20,7 +20,7 @@ export class BookBucketGateway implements OnGatewayConnection, OnGatewayDisconne
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token);
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
(client.data as Record<string, unknown>).user = user;
@@ -7,7 +7,9 @@ vi.mock('drizzle-orm', () => ({
gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })),
ilike: vi.fn((left: unknown, right: unknown) => ({ op: 'ilike', left, right })),
inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })),
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
notInArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'notInArray', left, right })),
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
sql: Object.assign(
vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
{
@@ -134,10 +136,9 @@ describe('BookBucketRepository', () => {
selectBuilder.limit.mockResolvedValue([{ id: 11 }, { id: 12 }]);
const repo = new BookBucketRepository(db as never);
await expect(repo.findSelectionBatch({ limit: 2, afterId: 10, excludedIds: [3], status: 'error', search: 'foo' })).resolves.toEqual([
{ id: 11 },
{ id: 12 },
]);
await expect(
repo.findSelectionBatch({ limit: 2, afterId: 10, excludedIds: [3], status: 'error', search: 'foo', userId: 1, isSuperuser: false }),
).resolves.toEqual([{ id: 11 }, { id: 12 }]);
expect(selectBuilder.orderBy).toHaveBeenCalled();
expect(selectBuilder.limit).toHaveBeenCalledWith(2);
});
@@ -156,6 +157,8 @@ describe('BookBucketRepository', () => {
sort: 'fileName',
order: 'asc',
search: 'dune',
userId: 1,
isSuperuser: true,
}),
).resolves.toEqual({
items: [{ id: 1 }, { id: 2 }],
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, count, desc, eq, gt, ilike, inArray, notInArray, sum, type SQL } from 'drizzle-orm';
import { and, asc, count, desc, eq, gt, ilike, inArray, isNull, notInArray, or, sum, type SQL } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -23,6 +23,8 @@ export interface ListOptions {
sort: string;
order: string;
search?: string;
userId: number;
isSuperuser: boolean;
}
export interface SelectionBatchOptions {
@@ -31,6 +33,8 @@ export interface SelectionBatchOptions {
excludedIds?: number[];
status?: string;
search?: string;
userId: number;
isSuperuser: boolean;
}
@Injectable()
@@ -38,7 +42,7 @@ export class BookBucketRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findAll(opts: ListOptions): Promise<{ items: BookBucketFileRow[]; total: number }> {
const conditions = this.buildSelectionConditions(opts.status, opts.search);
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.isSuperuser);
const where = conditions.length ? and(...conditions) : undefined;
@@ -93,8 +97,8 @@ export class BookBucketRepository {
await this.db.delete(bookBucketFiles).where(eq(bookBucketFiles.absolutePath, path));
}
async findAllIds(excludedIds?: number[], status?: string, search?: string): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search);
async findAllIds(excludedIds?: number[], status?: string, search?: string, userId?: number, isSuperuser?: boolean): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search, userId, isSuperuser ?? true);
if (excludedIds?.length) conditions.push(notInArray(bookBucketFiles.id, excludedIds));
const where = conditions.length ? and(...conditions) : undefined;
const rows = await this.db.select({ id: bookBucketFiles.id }).from(bookBucketFiles).where(where);
@@ -107,7 +111,7 @@ export class BookBucketRepository {
}
async findSelectionBatch(options: SelectionBatchOptions): Promise<BookBucketFileRow[]> {
const conditions = this.buildSelectionConditions(options.status, options.search);
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.isSuperuser);
if (options.excludedIds?.length) conditions.push(notInArray(bookBucketFiles.id, options.excludedIds));
if (options.afterId !== undefined) conditions.push(gt(bookBucketFiles.id, options.afterId));
const where = conditions.length ? and(...conditions) : undefined;
@@ -124,13 +128,15 @@ export class BookBucketRepository {
return updated.length;
}
async countsByStatus(): Promise<{ pending: number; ready: number; error: number; total: number }> {
async countsByStatus(userId?: number, isSuperuser?: boolean): Promise<{ pending: number; ready: number; error: number; total: number }> {
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, isSuperuser ?? true) : undefined;
const rows = await this.db
.select({
status: bookBucketFiles.status,
cnt: count(),
})
.from(bookBucketFiles)
.where(visibilityCondition)
.groupBy(bookBucketFiles.status);
const result = { pending: 0, ready: 0, error: 0, total: 0 };
@@ -144,10 +150,14 @@ export class BookBucketRepository {
return result;
}
async getStatistics(): Promise<{
async getStatistics(
userId?: number,
isSuperuser?: boolean,
): Promise<{
totalSizeBytes: number;
byFormat: { format: string; count: number; sizeBytes: number }[];
}> {
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, isSuperuser ?? true) : undefined;
const rows = await this.db
.select({
format: bookBucketFiles.format,
@@ -155,6 +165,7 @@ export class BookBucketRepository {
totalSize: sum(bookBucketFiles.fileSize),
})
.from(bookBucketFiles)
.where(visibilityCondition)
.groupBy(bookBucketFiles.format);
let totalSizeBytes = 0;
@@ -167,7 +178,12 @@ export class BookBucketRepository {
return { totalSizeBytes, byFormat };
}
private buildSelectionConditions(status?: string, search?: string): SQL[] {
private buildVisibilityCondition(userId: number, isSuperuser: boolean): SQL | undefined {
if (isSuperuser) return undefined;
return or(eq(bookBucketFiles.uploadedBy, userId), isNull(bookBucketFiles.uploadedBy));
}
private buildSelectionConditions(status?: string, search?: string, userId?: number, isSuperuser?: boolean): SQL[] {
const conditions: SQL[] = [];
if (status === 'pending') {
conditions.push(inArray(bookBucketFiles.status, ['pending', 'extracting', 'fetching']));
@@ -175,6 +191,9 @@ export class BookBucketRepository {
conditions.push(eq(bookBucketFiles.status, status));
}
if (search) conditions.push(ilike(bookBucketFiles.fileName, `%${search}%`));
if (userId !== undefined && !isSuperuser) {
conditions.push(or(eq(bookBucketFiles.uploadedBy, userId), isNull(bookBucketFiles.uploadedBy))!);
}
return conditions;
}
}
@@ -65,6 +65,8 @@ describe('BookBucketService', () => {
limit: 25,
sort: 'createdAt',
order: 'desc',
userId: 1,
isSuperuser: false,
});
expect(result.total).toBe(1);
@@ -63,7 +63,15 @@ export class BookBucketService {
await this.repo.deleteById(id);
}
async bulkDiscard(fileIds: number[], selectAll?: boolean, excludedIds?: number[], status?: string, search?: string): Promise<void> {
async bulkDiscard(
fileIds: number[],
selectAll?: boolean,
excludedIds?: number[],
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
): Promise<void> {
await this.processSelectionRows(
{
fileIds,
@@ -71,6 +79,8 @@ export class BookBucketService {
excludedIds,
status,
search,
userId,
isSuperuser,
},
async (rows) => {
for (const row of rows) {
@@ -90,6 +100,8 @@ export class BookBucketService {
mergeArrays: boolean,
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
let updated = 0;
let failed = 0;
@@ -100,6 +112,8 @@ export class BookBucketService {
excludedIds,
status,
search,
userId,
isSuperuser,
},
async (rows) => {
for (const row of rows) {
@@ -136,6 +150,8 @@ export class BookBucketService {
excludedIds?: number[],
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
): Promise<{ total: number; applied: number; skipped: number; skippedEdited: number }> {
let applied = 0;
let skipped = 0;
@@ -147,6 +163,8 @@ export class BookBucketService {
excludedIds,
status,
search,
userId,
isSuperuser,
},
async (rows) => {
for (const row of rows) {
@@ -173,6 +191,8 @@ export class BookBucketService {
excludedIds?: number[],
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
): Promise<{ total: number; queued: number }> {
let queued = 0;
const total = await this.processSelectionRows(
@@ -182,6 +202,8 @@ export class BookBucketService {
excludedIds,
status,
search,
userId,
isSuperuser,
},
(rows) => {
const errorRows = rows.filter((row) => row.status === 'error');
@@ -203,6 +225,8 @@ export class BookBucketService {
targetFolderId?: number | null,
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
await this.assertValidTarget(targetLibraryId, targetFolderId);
let updated = 0;
@@ -213,6 +237,8 @@ export class BookBucketService {
excludedIds,
status,
search,
userId,
isSuperuser,
},
async (rows) => {
updated += await this.repo.setTargetsByIds(
@@ -232,6 +258,8 @@ export class BookBucketService {
excludedIds?: number[],
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
): Promise<{ total: number; withDestination: number; withoutDestination: number }> {
const destinationPairCounts = new Map<string, number>();
const folderIdSet = new Set<number>();
@@ -242,6 +270,8 @@ export class BookBucketService {
excludedIds,
status,
search,
userId,
isSuperuser,
},
(rows) => {
for (const row of rows) {
@@ -275,12 +305,12 @@ export class BookBucketService {
return { total, withDestination, withoutDestination: total - withDestination };
}
async getSummary(): Promise<BookBucketSummary> {
return this.repo.countsByStatus();
async getSummary(userId?: number, isSuperuser?: boolean): Promise<BookBucketSummary> {
return this.repo.countsByStatus(userId, isSuperuser);
}
async getStatistics() {
return this.repo.getStatistics();
async getStatistics(userId?: number, isSuperuser?: boolean) {
return this.repo.getStatistics(userId, isSuperuser);
}
private async cleanupFiles(row: BookBucketFileRow): Promise<void> {
@@ -330,10 +360,14 @@ export class BookBucketService {
excludedIds?: number[];
status?: string;
search?: string;
userId?: number;
isSuperuser?: boolean;
},
processBatch: (rows: BookBucketFileRow[]) => Promise<void> | void,
): Promise<number> {
let total = 0;
const userId = options.userId ?? 0;
const isSuperuser = options.isSuperuser ?? true;
if (options.selectAll) {
let afterId: number | undefined;
@@ -344,6 +378,8 @@ export class BookBucketService {
excludedIds: options.excludedIds,
status: options.status,
search: options.search,
userId,
isSuperuser,
});
if (rows.length === 0) break;
await processBatch(rows);
@@ -1,4 +1,4 @@
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { Transform } from 'class-transformer';
const VALID_STATUSES = ['pending', 'ready', 'error'] as const;
@@ -33,5 +33,6 @@ export class ListBookBucketFilesDto {
@IsOptional()
@IsString()
@MaxLength(500)
search?: string;
}
@@ -30,7 +30,7 @@ export class BookMetadataFetchGateway implements OnGatewayConnection, OnGatewayD
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token);
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new UnauthorizedException('User not found or token revoked');
@@ -763,3 +763,47 @@ describe('isbnRuleToSql', () => {
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'isbn', operator: 'eq' }) as never, BASE_CTX)).toThrow(BadRequestException);
});
});
describe('ILIKE pattern escaping (SEC-013)', () => {
it('escapes literal percent signs in contains patterns', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'contains', value: '100%' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause.pattern).toBe('%100\\%%');
});
it('escapes literal underscores in contains patterns', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'contains', value: 'a_b' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause.pattern).toBe('%a\\_b%');
});
it('escapes backslashes before other special characters', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'contains', value: 'a\\%b' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause.pattern).toBe('%a\\\\\\%b%');
});
it('escapes percent signs in startsWith patterns', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'startsWith', value: '50%off' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause.pattern).toBe('50\\%off%');
});
it('escapes underscores in endsWith patterns', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'endsWith', value: '_end' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause.pattern).toBe('%\\_end');
});
it('does not alter patterns with no special characters', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause.pattern).toBe('%Dune%');
});
});
@@ -179,17 +179,17 @@ export class BookQueryBuilder {
}
switch (operator) {
case 'contains':
return ilike(col, `%${value}%`);
return ilike(col, `%${escapeLikePattern(value!)}%`);
case 'notContains':
return not(ilike(col, `%${value}%`));
return not(ilike(col, `%${escapeLikePattern(value!)}%`));
case 'startsWith':
return ilike(col, `${value}%`);
return ilike(col, `${escapeLikePattern(value!)}%`);
case 'endsWith':
return ilike(col, `%${value}`);
return ilike(col, `%${escapeLikePattern(value!)}`);
case 'eq':
return ilike(col, value!);
return ilike(col, escapeLikePattern(value!));
case 'notEq':
return not(ilike(col, value!));
return not(ilike(col, escapeLikePattern(value!)));
case 'isEmpty':
return or(isNull(col), eq(col, ''))!;
case 'isNotEmpty':
@@ -499,3 +499,7 @@ export class BookQueryBuilder {
}
}
}
function escapeLikePattern(s: string): string {
return s.replace(/[\\%_]/g, '\\$&');
}
@@ -1,9 +1,10 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min, MinLength } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
export class SearchBooksDto {
@IsString()
@MinLength(1)
@MaxLength(500)
q: string;
@IsOptional()
@@ -41,7 +41,7 @@ describe('CoverController', () => {
};
coverService.proxyImage.mockResolvedValue({ buffer, contentType: 'image/jpeg' });
await controller.proxyImage('https://covers.example.com/dune.jpg', reply as never);
await controller.proxyImage({ url: 'https://covers.example.com/dune.jpg' }, reply as never);
expect(coverService.proxyImage).toHaveBeenCalledWith('https://covers.example.com/dune.jpg');
expect(reply.type).toHaveBeenCalledWith('image/jpeg');
+3 -2
View File
@@ -8,6 +8,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { CoverService } from './cover.service';
import { SearchCoversQueryDto } from './dto/search-covers-query.dto';
import { UploadCoverFromUrlDto } from './dto/upload-cover-from-url.dto';
import { ProxyCoverQueryDto } from './dto/proxy-cover-query.dto';
@Controller('books')
export class CoverController {
@@ -26,8 +27,8 @@ export class CoverController {
@Get('cover/proxy')
@RequirePermission(Permission.LibraryEditMetadata)
async proxyImage(@Query('url') url: string, @Res() res: FastifyReply) {
const { buffer, contentType } = await this.coverService.proxyImage(url);
async proxyImage(@Query() query: ProxyCoverQueryDto, @Res() res: FastifyReply) {
const { buffer, contentType } = await this.coverService.proxyImage(query.url);
res.type(contentType).send(buffer);
}
+3 -80
View File
@@ -1,14 +1,13 @@
import { lookup } from 'dns/promises';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CoverSearchResult } from '@projectx/types';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises';
import { isIP } from 'net';
import { join } from 'path';
import { bookCoverDirPath, bookThumbnailPath, findExtractedBookCoverFileName } from '../../common/book-cover-storage';
import type { RequestUser } from '../../common/types/request-user';
import { ensureSafeRemoteHost } from '../../common/utils/ssrf.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata } from '../../db/schema';
@@ -281,7 +280,7 @@ export class CoverService {
throw new BadRequestException('URL must use http or https');
}
await this.ensureSafeRemoteHost(parsedUrl.hostname);
await ensureSafeRemoteHost(parsedUrl.hostname);
return parsedUrl;
}
@@ -339,42 +338,10 @@ export class CoverService {
throw new BadRequestException('URL must use http or https');
}
await this.ensureSafeRemoteHost(redirectUrl.hostname);
await ensureSafeRemoteHost(redirectUrl.hostname);
return redirectUrl;
}
private async ensureSafeRemoteHost(hostname: string): Promise<void> {
const normalizedHost = hostname.trim().toLowerCase();
if (!normalizedHost) throw new BadRequestException('URL host is required');
if (normalizedHost === 'localhost' || normalizedHost.endsWith('.localhost') || normalizedHost.endsWith('.local')) {
throw new BadRequestException('URL host is not allowed');
}
// URL.hostname wraps IPv6 in brackets (e.g. [::1]); strip them for isIP/range checks
const bareHost = normalizedHost.startsWith('[') && normalizedHost.endsWith(']') ? normalizedHost.slice(1, -1) : normalizedHost;
const ipFamily = isIP(bareHost);
if (ipFamily > 0) {
if (isPrivateOrLocalAddress(bareHost)) {
throw new BadRequestException('URL host is not allowed');
}
return;
}
let resolved;
try {
resolved = await lookup(normalizedHost, { all: true, verbatim: true });
} catch {
throw new BadRequestException('Unable to resolve URL host');
}
if (resolved.length === 0) throw new BadRequestException('Unable to resolve URL host');
if (resolved.some((entry) => isPrivateOrLocalAddress(entry.address))) {
throw new BadRequestException('URL host is not allowed');
}
}
private extractImageContentType(response: Response): string {
const headerValue = response.headers.get('content-type') ?? '';
const contentType = headerValue.split(';')[0]?.trim().toLowerCase();
@@ -457,47 +424,3 @@ function sanitizeErrorMessage(error: unknown): string {
function errorClass(error: unknown): string {
return error instanceof Error ? error.name : 'Error';
}
function isPrivateOrLocalAddress(address: string): boolean {
const normalized = address.toLowerCase();
const mappedV4Prefix = '::ffff:';
const maybeV4 = normalized.startsWith(mappedV4Prefix) ? normalized.slice(mappedV4Prefix.length) : normalized;
const family = isIP(maybeV4);
if (family === 4) {
return isPrivateOrLocalV4(maybeV4);
}
if (family === 6) {
return (
maybeV4 === '::1' ||
maybeV4 === '::' ||
maybeV4.startsWith('fc') ||
maybeV4.startsWith('fd') ||
maybeV4.startsWith('fe8') ||
maybeV4.startsWith('fe9') ||
maybeV4.startsWith('fea') ||
maybeV4.startsWith('feb')
);
}
return true;
}
function isPrivateOrLocalV4(address: string): boolean {
const octets = address.split('.').map((part) => Number(part));
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
return true;
}
if (octets[0] === 10) return true;
if (octets[0] === 127) return true;
if (octets[0] === 0) return true;
if (octets[0] === 169 && octets[1] === 254) return true;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
if (octets[0] === 192 && octets[1] === 168) return true;
if (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127) return true;
if (octets[0] >= 224) return true;
return false;
}
@@ -0,0 +1,7 @@
import { IsUrl, MaxLength } from 'class-validator';
export class ProxyCoverQueryDto {
@IsUrl({ protocols: ['http', 'https'], require_tld: true })
@MaxLength(2048)
url: string;
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { CoverSearchResult, type ITunesCoverResolution } from '@projectx/types';
import { ProviderConfigService } from '../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { CoverProvider, CoverSearchParams, ITUNES_PROVIDER_KEY } from './cover-provider';
type ITunesCoverSearchResult = {
@@ -47,7 +48,7 @@ export class ITunesCoverProvider implements CoverProvider {
try {
const response = await fetch(url.toString(), { signal: AbortSignal.timeout(10_000) });
if (!response.ok) {
this.logger.warn(`Search failed with status ${response.status} for query "${query}"`);
this.logger.warn(`Search failed with status ${response.status} for query "${sanitizeLogValue(query)}"`);
return [];
}
@@ -68,7 +69,7 @@ export class ITunesCoverProvider implements CoverProvider {
});
return mapped.filter((result): result is CoverSearchResult => result !== null);
} catch (error) {
this.logger.warn(`Search failed for query "${query}": ${error instanceof Error ? error.message : String(error)}`);
this.logger.warn(`Search failed for query "${sanitizeLogValue(query)}": ${error instanceof Error ? error.message : String(error)}`);
return [];
}
}
@@ -1,10 +1,12 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateEmailProviderDto {
@IsString()
@MaxLength(255)
name: string;
@IsString()
@MaxLength(253)
host: string;
@IsInt()
@@ -14,18 +16,22 @@ export class CreateEmailProviderDto {
@IsOptional()
@IsString()
@MaxLength(255)
username?: string;
@IsOptional()
@IsString()
@MaxLength(1024)
password?: string;
@IsOptional()
@IsString()
@MaxLength(255)
fromName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
fromAddress?: string;
@IsBoolean()
@@ -1,7 +1,8 @@
import { IsInt, IsOptional, IsString } from 'class-validator';
import { IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateEmailRecipientGroupDto {
@IsString()
@MaxLength(255)
name: string;
@IsOptional()
@@ -1,9 +1,10 @@
import { IsEmail, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { IsEmail, IsIn, IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
import { RECIPIENT_DEVICE_TYPES, RECIPIENT_FORMATS } from './email-recipient.constants';
export class CreateEmailRecipientDto {
@IsString()
@MaxLength(255)
name: string;
@IsEmail()
@@ -1,12 +1,15 @@
import { IsString } from 'class-validator';
import { IsString, MaxLength } from 'class-validator';
export class CreateEmailTemplateDto {
@IsString()
@MaxLength(255)
name: string;
@IsString()
@MaxLength(998)
subject: string;
@IsString()
@MaxLength(200_000)
bodyText: string;
}
@@ -1,12 +1,14 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class UpdateEmailProviderDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(253)
host?: string;
@IsOptional()
@@ -17,18 +19,22 @@ export class UpdateEmailProviderDto {
@IsOptional()
@IsString()
@MaxLength(255)
username?: string;
@IsOptional()
@IsString()
@MaxLength(1024)
password?: string;
@IsOptional()
@IsString()
@MaxLength(255)
fromName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
fromAddress?: string;
@IsOptional()
@@ -1,8 +1,9 @@
import { IsInt, IsOptional, IsString } from 'class-validator';
import { IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateEmailRecipientGroupDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@@ -1,10 +1,11 @@
import { IsEmail, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { IsEmail, IsIn, IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
import { RECIPIENT_DEVICE_TYPES, RECIPIENT_FORMATS } from './email-recipient.constants';
export class UpdateEmailRecipientDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@@ -1,15 +1,18 @@
import { IsOptional, IsString } from 'class-validator';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateEmailTemplateDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(998)
subject?: string;
@IsOptional()
@IsString()
@MaxLength(200_000)
bodyText?: string;
}
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class KoboAuthDeviceBodyDto {
@IsString()
@IsOptional()
UserKey?: string;
}
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class KoboAuthRefreshBodyDto {
@IsString()
@IsOptional()
RefreshToken?: string;
}
@@ -3,6 +3,8 @@ import { randomUUID } from 'crypto';
import { Public } from '../../common/decorators/public.decorator';
import { KoboTokenGuard } from './guards/kobo-token.guard';
import { KoboAuthDeviceBodyDto } from './dto/kobo-auth-device-body.dto';
import { KoboAuthRefreshBodyDto } from './dto/kobo-auth-refresh-body.dto';
@Controller('kobo/:deviceToken')
@Public()
@@ -10,7 +12,7 @@ import { KoboTokenGuard } from './guards/kobo-token.guard';
export class KoboAuthController {
@Post('v1/auth/device')
@HttpCode(HttpStatus.OK)
authDevice(@Body() body: Record<string, unknown>) {
authDevice(@Body() body: KoboAuthDeviceBodyDto) {
return {
AccessToken: randomUUID(),
RefreshToken: randomUUID(),
@@ -22,7 +24,7 @@ export class KoboAuthController {
@Post('v1/auth/refresh')
@HttpCode(HttpStatus.OK)
authRefresh(@Body() body: Record<string, unknown>) {
authRefresh(@Body() body: KoboAuthRefreshBodyDto) {
return {
AccessToken: randomUUID(),
RefreshToken: body.RefreshToken ?? randomUUID(),
@@ -1,4 +1,21 @@
import { Body, Controller, Delete, Get, Header, Headers, HttpCode, HttpStatus, Logger, Param, Put, Req, Res, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Header,
Headers,
HttpCode,
HttpStatus,
Logger,
Param,
Put,
Req,
Res,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Public } from '../../common/decorators/public.decorator';
@@ -130,6 +147,7 @@ export class KoboSyncController {
}
@Put('v1/library/:bookId/state')
@UsePipes(new ValidationPipe({ transform: false, whitelist: false }))
async updateReadingState(
@Param('bookId') bookId: string,
@Body() body: Record<string, unknown>,
@@ -1,9 +1,10 @@
import { ArrayMinSize, IsArray, IsNotEmpty, IsString } from 'class-validator';
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class PrescanLibraryDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
@IsNotEmpty({ each: true })
@MaxLength(4096, { each: true })
paths: string[];
}
@@ -1,4 +1,4 @@
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { MetadataProviderKey } from '@projectx/types';
@@ -8,5 +8,6 @@ export class LookupMetadataDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
id: string;
}
@@ -6,6 +6,7 @@ import {
Min,
IsOptional,
IsString,
MaxLength,
registerDecorator,
ValidationArguments,
ValidationOptions,
@@ -50,14 +51,17 @@ export class MetadataSearchDto {
@IsOptional()
@IsString()
@MaxLength(500)
title?: string;
@IsOptional()
@IsString()
@MaxLength(255)
author?: string;
@IsOptional()
@IsString()
@MaxLength(30)
isbn?: string;
@IsOptional()
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
@@ -101,29 +102,31 @@ export class AmazonProvider implements IdentifiableProvider {
): Promise<string | null> {
const headers: HeadersInit = cookie ? { ...HEADERS, cookie } : HEADERS;
const startedAt = Date.now();
this.logger.log(`[amazon] [start] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''}`);
const safeQuery = query ? sanitizeLogValue(query) : undefined;
const safeProviderId = providerId ? sanitizeLogValue(providerId) : undefined;
this.logger.log(`[amazon] [start] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}`);
try {
const res = await fetchWithThrottle(url, { headers, signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.SCRAPE, signal) });
if (!res.ok) {
this.logger.warn(
`[amazon] [fail] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
`[amazon] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
);
return null;
}
const html = await res.text();
this.logger.log(
`[amazon] [end] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt}`,
`[amazon] [end] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt}`,
);
return html;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(
`[amazon] [fail] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} durationMs=${Date.now() - startedAt} message="throttled"`,
`[amazon] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="throttled"`,
);
throw err;
}
this.logger.warn(
`[amazon] [fail] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
`[amazon] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
);
return null;
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
@@ -36,29 +37,30 @@ export class AudibleProvider implements IdentifiableProvider {
url.searchParams.set('response_groups', 'product_desc,media,product_attrs,series,product_plan_details,category_ladders');
const requestUrl = url.toString();
const startedAt = Date.now();
this.logger.log(`[audible] [start] op=search query="${query}"`);
const safeQuery = sanitizeLogValue(query);
this.logger.log(`[audible] [start] op=search query="${safeQuery}"`);
try {
const res = await fetchWithThrottle(requestUrl, { signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, params.signal) });
if (!res.ok) {
this.logger.warn(
`[audible] [fail] op=search query="${query}" status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
`[audible] [fail] op=search query="${safeQuery}" status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
);
return [];
}
const body = (await res.json()) as AudibleSearchResponse;
const results = (body.products ?? []).map(mapAudibleProduct);
this.logger.log(
`[audible] [end] op=search query="${query}" status=${res.status} resultCount=${results.length} durationMs=${Date.now() - startedAt}`,
`[audible] [end] op=search query="${safeQuery}" status=${res.status} resultCount=${results.length} durationMs=${Date.now() - startedAt}`,
);
return results;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(`[audible] [fail] op=search query="${query}" durationMs=${Date.now() - startedAt} message="throttled"`);
this.logger.warn(`[audible] [fail] op=search query="${safeQuery}" durationMs=${Date.now() - startedAt} message="throttled"`);
throw err;
}
this.logger.error(
`[audible] [fail] op=search query="${query}" durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
`[audible] [fail] op=search query="${safeQuery}" durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
);
return [];
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { normalizeAudibleDomain } from '../audible/normalize-audible-domain';
@@ -63,29 +64,32 @@ export class AudnexusProvider implements MetadataProvider {
url.searchParams.set('response_groups', AUDIBLE_RESPONSE_GROUPS);
const requestUrl = url.toString();
const startedAt = Date.now();
this.logger.log(`[audnexus] [start] op=resolve-asin method=GET query="${query}"`);
const safeQuery = sanitizeLogValue(query);
this.logger.log(`[audnexus] [start] op=resolve-asin method=GET query="${safeQuery}"`);
try {
const res = await fetchWithThrottle(requestUrl, { signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal) });
if (!res.ok) {
this.logger.warn(
`[audnexus] [fail] op=resolve-asin method=GET query="${query}" status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
`[audnexus] [fail] op=resolve-asin method=GET query="${safeQuery}" status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
);
return null;
}
const body = (await res.json()) as AudibleSearchResponse;
const asin = body.products?.[0]?.asin?.trim() || null;
this.logger.log(
`[audnexus] [end] op=resolve-asin method=GET query="${query}" status=${res.status} found=${asin != null} durationMs=${Date.now() - startedAt}`,
`[audnexus] [end] op=resolve-asin method=GET query="${safeQuery}" status=${res.status} found=${asin != null} durationMs=${Date.now() - startedAt}`,
);
return asin;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(`[audnexus] [fail] op=resolve-asin method=GET query="${query}" durationMs=${Date.now() - startedAt} message="throttled"`);
this.logger.warn(
`[audnexus] [fail] op=resolve-asin method=GET query="${safeQuery}" durationMs=${Date.now() - startedAt} message="throttled"`,
);
throw err;
}
this.logger.error(
`[audnexus] [fail] op=resolve-asin method=GET query="${query}" durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
`[audnexus] [fail] op=resolve-asin method=GET query="${safeQuery}" durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
);
return null;
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
@@ -84,30 +85,34 @@ export class GoodreadsProvider implements IdentifiableProvider {
providerId?: string,
signal?: AbortSignal,
): Promise<string | null> {
const safeQuery = query ? sanitizeLogValue(query) : undefined;
const safeProviderId = providerId ? sanitizeLogValue(providerId) : undefined;
const startedAt = Date.now();
this.logger.log(`[goodreads] [start] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''}`);
this.logger.log(
`[goodreads] [start] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}`,
);
try {
const res = await fetchWithThrottle(url, { headers: HEADERS, signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.SCRAPE, signal) });
if (!res.ok) {
this.logger.warn(
`[goodreads] [fail] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
);
return null;
}
const html = await res.text();
this.logger.log(
`[goodreads] [end] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt}`,
`[goodreads] [end] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt}`,
);
return html;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(
`[goodreads] [fail] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} durationMs=${Date.now() - startedAt} message="throttled"`,
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="throttled"`,
);
throw err;
}
this.logger.warn(
`[goodreads] [fail] op=${op}${query ? ` query="${query}"` : ''}${providerId ? ` providerId="${providerId}"` : ''} durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
);
return null;
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
@@ -75,29 +76,30 @@ export class GoogleProvider implements IdentifiableProvider {
private async fetchVolumes(query: string, apiKey: string, signal?: AbortSignal): Promise<MetadataCandidate[]> {
const url = this.buildUrl('/volumes', { q: query, maxResults: '10', printType: 'books' }, apiKey);
const startedAt = Date.now();
this.logger.log(`[google] [start] op=search query="${query}"`);
const safeQuery = sanitizeLogValue(query);
this.logger.log(`[google] [start] op=search query="${safeQuery}"`);
try {
const res = await fetchWithThrottle(url, { signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal) });
if (!res.ok) {
this.logger.warn(
`[google] [fail] op=search query="${query}" status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
`[google] [fail] op=search query="${safeQuery}" status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
);
return [];
}
const body = (await res.json()) as GoogleBooksResponse;
const items = (body.items ?? []).map(mapGoogleVolume);
this.logger.log(
`[google] [end] op=search query="${query}" status=${res.status} resultCount=${items.length} durationMs=${Date.now() - startedAt}`,
`[google] [end] op=search query="${safeQuery}" status=${res.status} resultCount=${items.length} durationMs=${Date.now() - startedAt}`,
);
return items;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(`[google] [fail] op=search query="${query}" durationMs=${Date.now() - startedAt} message="throttled"`);
this.logger.warn(`[google] [fail] op=search query="${safeQuery}" durationMs=${Date.now() - startedAt} message="throttled"`);
throw err;
}
this.logger.warn(
`[google] [fail] op=search query="${query}" durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
`[google] [fail] op=search query="${safeQuery}" durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
);
throw err;
}
@@ -32,8 +32,11 @@ function makeGateway() {
findRunById: vi.fn(),
listRunMetrics: vi.fn(),
};
const configService = {
get: vi.fn().mockReturnValue('http://localhost:5173'),
};
const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never);
const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never, configService as never);
return { gateway, jwtService, authService, repo };
}
@@ -49,6 +52,24 @@ function makeClient() {
}
describe('MigrationProgressGateway', () => {
it('afterInit injects runtime cors origin and credentials on socket engine', () => {
const { gateway } = makeGateway();
const server = { engine: { opts: { cors: { methods: ['GET'] } } } } as any;
gateway.afterInit(server);
expect(server.engine.opts.cors).toEqual({
methods: ['GET'],
origin: 'http://localhost:5173',
credentials: true,
});
});
it('afterInit safely no-ops when engine options are unavailable', () => {
const { gateway } = makeGateway();
expect(() => gateway.afterInit({} as any)).not.toThrow();
});
it('accepts websocket connections for authorized users', async () => {
const { gateway, jwtService, authService } = makeGateway();
const client = makeClient();
@@ -57,7 +78,7 @@ describe('MigrationProgressGateway', () => {
await gateway.handleConnection(client as never);
expect(jwtService.verify).toHaveBeenCalledWith('token-1');
expect(jwtService.verify).toHaveBeenCalledWith('token-1', { algorithms: ['HS256'] });
expect(authService.validateUser).toHaveBeenCalledWith(7, 1);
expect(client.disconnect).not.toHaveBeenCalled();
expect((client.data as { user?: RequestUser }).user?.id).toBe(7);
@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Permission, type MigrationProgressEvent, type MigrationRunState } from '@projectx/types';
@@ -9,22 +10,35 @@ import { AuthService } from '../auth/auth.service';
import { MigrationRepository } from './migration.repository';
import { sanitizeRunForApi } from './core/api-sanitizers';
@WebSocketGateway({ namespace: '/migration', cors: { origin: '*', credentials: true } })
export class MigrationProgressGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketGateway({ namespace: '/migration', cors: { credentials: true } })
export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
private readonly logger = new Logger(MigrationProgressGateway.name);
private readonly clientOrigin: string;
constructor(
private readonly jwtService: JwtService,
private readonly authService: AuthService,
private readonly repo: MigrationRepository,
) {}
config: ConfigService,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
}
afterInit(server: Server): void {
if (!server.engine?.opts) return;
server.engine.opts.cors = {
...(server.engine.opts.cors ?? {}),
origin: this.clientOrigin,
credentials: true,
};
}
async handleConnection(client: Socket): Promise<void> {
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token);
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
this.assertCanViewMigrationProgress(user);
+46 -16
View File
@@ -1,14 +1,14 @@
vi.mock('fs/promises', () => ({
readdir: vi.fn(),
stat: vi.fn(),
lstat: vi.fn(),
}));
import { readdir, stat } from 'fs/promises';
import { readdir, lstat } from 'fs/promises';
import { PathService } from './path.service';
const readdirMock = vi.mocked(readdir);
const statMock = vi.mocked(stat);
const lstatMock = vi.mocked(lstat);
function entry(name: string, options: { directory?: boolean; symbolicLink?: boolean } = {}) {
return {
@@ -32,32 +32,62 @@ describe('PathService', () => {
expect(readdirMock).not.toHaveBeenCalled();
});
it('lists only accessible non-hidden directories and sorts them by name', async () => {
it('rejects symlinked root paths', async () => {
lstatMock.mockResolvedValue({ isSymbolicLink: () => true, isDirectory: () => false } as never);
await expect(service.listDirectories('/tmp/books')).resolves.toEqual([]);
});
it('lists only accessible real (non-symlink) directories and sorts them by name', async () => {
lstatMock.mockImplementation((fullPath) => {
if (String(fullPath) === '/tmp/books') {
return Promise.resolve({ isSymbolicLink: () => false, isDirectory: () => true } as never);
}
if (String(fullPath).endsWith('/beta')) {
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
if (String(fullPath).endsWith('/alpha-link')) {
return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => true } as never);
}
return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => false } as never);
});
readdirMock.mockResolvedValue([
entry('notes.txt'),
entry('.cache', { directory: true }),
entry('zeta', { directory: true }),
entry('alpha-link', { symbolicLink: true }),
entry('alpha-link', { directory: true }),
entry('beta', { directory: true }),
] as never);
statMock.mockImplementation((fullPath) => {
if (String(fullPath).endsWith('/beta')) {
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
await expect(service.listDirectories('/tmp/books')).resolves.toEqual([{ name: 'zeta', path: '/tmp/books/zeta' }]);
});
return Promise.resolve({
isDirectory: () => true,
} as never);
it('excludes entries that appear as directories but are symlinks when lstat confirms it', async () => {
lstatMock.mockImplementation((fullPath) => {
if (String(fullPath) === '/tmp/books') {
return Promise.resolve({ isSymbolicLink: () => false, isDirectory: () => true } as never);
}
if (String(fullPath).endsWith('/real-dir')) {
return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => false } as never);
}
return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => true } as never);
});
await expect(service.listDirectories('/tmp/books')).resolves.toEqual([
{ name: 'alpha-link', path: '/tmp/books/alpha-link' },
{ name: 'zeta', path: '/tmp/books/zeta' },
]);
readdirMock.mockResolvedValue([entry('real-dir', { directory: true }), entry('sym-dir', { directory: true })] as never);
await expect(service.listDirectories('/tmp/books')).resolves.toEqual([{ name: 'real-dir', path: '/tmp/books/real-dir' }]);
});
it('rejects /etc and /root as blocked paths', async () => {
await expect(service.listDirectories('/etc')).resolves.toEqual([]);
await expect(service.listDirectories('/root')).resolves.toEqual([]);
await expect(service.listDirectories('/etc/passwd')).resolves.toEqual([]);
expect(readdirMock).not.toHaveBeenCalled();
});
it('returns empty when reading the target directory fails', async () => {
lstatMock.mockResolvedValue({ isSymbolicLink: () => false, isDirectory: () => true } as never);
readdirMock.mockRejectedValue(new Error('missing'));
await expect(service.listDirectories('/does/not/exist')).resolves.toEqual([]);
+8 -5
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { readdir, stat } from 'fs/promises';
import { lstat, readdir } from 'fs/promises';
import { join, resolve, sep } from 'path';
const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run'];
const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run', '/etc', '/root'];
@Injectable()
export class PathService {
@@ -12,15 +12,18 @@ export class PathService {
return [];
}
try {
const rootStat = await lstat(resolved);
if (rootStat.isSymbolicLink()) return [];
const entries = await readdir(resolved, { withFileTypes: true });
const dirs: { name: string; path: string }[] = [];
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.')) continue;
const full = join(resolved, entry.name);
try {
const s = await stat(full);
if (s.isDirectory()) dirs.push({ name: entry.name, path: full });
const s = await lstat(full);
if (s.isDirectory() && !s.isSymbolicLink()) dirs.push({ name: entry.name, path: full });
} catch {
// skip inaccessible entries
}
+5 -5
View File
@@ -95,9 +95,9 @@ async function collectByDir(
if (entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
if (entry.isDirectory()) {
if (entry.isDirectory() && !entry.isSymbolicLink()) {
subdirs.push(full);
} else if (entry.isFile()) {
} else if (entry.isFile() && !entry.isSymbolicLink()) {
if (full.length > MAX_PATH_LENGTH) {
logger?.(`Path exceeds ${MAX_PATH_LENGTH} characters, skipping: ${full}`);
continue;
@@ -296,13 +296,13 @@ export async function buildSingleBookCandidate(
if (entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(folderPath, entry.name);
if (entry.isDirectory()) {
if (entry.isDirectory() && !entry.isSymbolicLink()) {
if (isDiscDirectory(entry.name)) {
discDirs.push(full);
} else {
nonDiscDirs.push({ name: entry.name, path: full });
}
} else if (entry.isFile() && full.length <= MAX_PATH_LENGTH) {
} else if (entry.isFile() && !entry.isSymbolicLink() && full.length <= MAX_PATH_LENGTH) {
filePaths.push(full);
}
}
@@ -310,7 +310,7 @@ export async function buildSingleBookCandidate(
for (const discDir of discDirs) {
const discEntries = await readdir(discDir, { withFileTypes: true }).catch(() => []);
for (const entry of discEntries) {
if (!entry.isFile() || entry.name.startsWith('.')) continue;
if (!entry.isFile() || entry.isSymbolicLink() || entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(discDir, entry.name);
if (full.length <= MAX_PATH_LENGTH) filePaths.push(full);
@@ -6,7 +6,8 @@ function makeGateway() {
const jwtService = { verify: vi.fn() };
const authService = { validateUser: vi.fn() };
const scanJobStore = { get: vi.fn(), isRunning: vi.fn() };
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any);
const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') };
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, configService as any);
return { gateway, jwtService, authService, scanJobStore };
}
@@ -102,7 +103,7 @@ describe('handleConnection', () => {
await gateway.handleConnection(client);
expect(jwtService.verify).toHaveBeenCalledWith('valid');
expect(jwtService.verify).toHaveBeenCalledWith('valid', { algorithms: ['HS256'] });
expect(authService.validateUser).toHaveBeenCalledWith(42, 3);
expect(client.data.user).toEqual({ id: 42, username: 'reader' });
expect(disconnect).not.toHaveBeenCalled();
@@ -128,6 +129,24 @@ describe('handleConnection', () => {
});
describe('subscription lifecycle', () => {
it('afterInit injects runtime cors origin and credentials on socket engine', () => {
const { gateway } = makeGateway();
const server = { engine: { opts: { cors: { methods: ['GET'] } } } } as any;
gateway.afterInit(server);
expect(server.engine.opts.cors).toEqual({
methods: ['GET'],
origin: 'http://localhost:5173',
credentials: true,
});
});
it('afterInit safely no-ops when engine options are unavailable', () => {
const { gateway } = makeGateway();
expect(() => gateway.afterInit({} as any)).not.toThrow();
});
it('logs disconnects and emits an in-flight progress snapshot on subscribe', () => {
const { gateway, scanJobStore } = makeGateway();
scanJobStore.get.mockReturnValue({
+19 -5
View File
@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type {
@@ -15,22 +16,35 @@ import type {
import { AuthService } from '../auth/auth.service';
import { ScanJobStore } from './scan-job-store.service';
@WebSocketGateway({ namespace: '/scan', cors: { origin: '*', credentials: true } })
export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
private readonly logger = new Logger(ScanGateway.name);
private readonly clientOrigin: string;
constructor(
private readonly jwtService: JwtService,
private readonly authService: AuthService,
private readonly scanJobStore: ScanJobStore,
) {}
config: ConfigService,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
}
afterInit(server: Server): void {
if (!server.engine?.opts) return;
server.engine.opts.cors = {
...(server.engine.opts.cors ?? {}),
origin: this.clientOrigin,
credentials: true,
};
}
async handleConnection(client: Socket): Promise<void> {
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token);
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
(client.data as Record<string, unknown>).user = user;
@@ -1,13 +1,15 @@
import { Permission } from '@projectx/types';
import { IsArray, IsEmail, IsEnum, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
import { IsArray, IsEmail, IsEnum, IsInt, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(3)
@MaxLength(100)
username: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name: string;
@IsEmail()
+2 -5
View File
@@ -1,15 +1,12 @@
import { IsEmail, IsObject, IsOptional, IsString, MinLength } from 'class-validator';
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateMeDto {
@IsString()
@MinLength(1)
@MaxLength(255)
@IsOptional()
name?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsObject()
@IsOptional()
settings?: Record<string, unknown>;
@@ -1,8 +1,9 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
import { IsBoolean, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateUserDto {
@IsString()
@MinLength(1)
@MaxLength(255)
@IsOptional()
name?: string;
+4 -3
View File
@@ -207,10 +207,11 @@ describe('UserService', () => {
await expect(service.updateUser(2, { name: 'x' }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
it('updateMe rejects duplicate email conflicts explicitly', async () => {
userRepo.findByEmail.mockResolvedValue({ id: 9 });
it('SEC-030: updateMe ignores email field (email changes not permitted via self-service)', async () => {
userRepo.update.mockResolvedValue({ id: 2, name: 'Alice', email: 'alice@example.com' });
await expect(service.updateMe(2, { email: 'dup@example.com' })).rejects.toBeInstanceOf(ConflictException);
await service.updateMe(2, {} as never);
expect(userRepo.findByEmail).not.toHaveBeenCalled();
});
it('updateMe throws when repository update returns null', async () => {
-3
View File
@@ -130,9 +130,6 @@ export class UserService {
}
async updateMe(userId: number, dto: UpdateMeDto) {
if (dto.email !== undefined && dto.email !== null) {
await this.assertEmailAvailable(dto.email, userId);
}
const user = await this.userRepo.update(userId, dto);
if (!user) throw new NotFoundException('User not found');
return user;
@@ -1,5 +1,10 @@
import { randomUUID } from 'crypto';
// Mock DNS resolution so ensureSafeUrl passes for the fake issuer.example domain
vi.mock('dns/promises', () => ({
lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34', family: 4 }]),
}));
import fastifyCookie from '@fastify/cookie';
import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
@@ -270,14 +275,54 @@ async function createOidcContractContext(): Promise<OidcContractContext> {
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
expect(setupResponse.statusCode).toBe(201);
const setupBody = setupResponse.json() as { accessToken: string };
let adminToken: string;
if (setupResponse.statusCode === 201) {
const body = setupResponse.json() as { accessToken: string };
adminToken = body.accessToken;
} else if (setupResponse.statusCode === 409) {
const db = app.get<Db>(DB);
const loginResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: ADMIN_SETUP_DTO.username, password: ADMIN_SETUP_DTO.password },
});
if (loginResponse.statusCode === 200) {
const body = loginResponse.json() as { accessToken: string };
adminToken = body.accessToken;
} else {
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `app-settings-oidc-e2e-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 4);
await db.insert(schema.users).values({
username: fallbackUsername,
name: 'App Settings OIDC E2E Admin (fallback)',
email: `${fallbackUsername}@example.com`,
passwordHash,
isSuperuser: true,
isDefaultPassword: false,
provisioningMethod: 'local',
});
const fallbackResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: fallbackUsername, password: ADMIN_SETUP_DTO.password },
});
if (fallbackResponse.statusCode !== 200) {
throw new Error(`OIDC contract test: fallback admin login failed (${fallbackResponse.statusCode})`);
}
const body = fallbackResponse.json() as { accessToken: string };
adminToken = body.accessToken;
}
} else {
throw new Error(`OIDC contract test: unexpected setup response ${setupResponse.statusCode}: ${setupResponse.body}`);
}
const db = app.get<Db>(DB);
return {
app,
db,
adminToken: setupBody.accessToken,
adminToken,
fetchMock,
oidcDiscoveryMock,
oidcTokenClientMock,
@@ -493,8 +538,8 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
idToken: 'oidc-id-provisioned',
});
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
sub: 'oidc-provisioned-subject',
sid: 'oidc-provisioned-sid',
sub: `oidc-provisioned-subject-${suffix}`,
sid: `oidc-provisioned-sid-${suffix}`,
preferred_username: username,
name: 'OIDC Provisioned User',
email,
@@ -554,7 +599,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
expect(createdUser).toMatchObject({
username,
email,
oidcSubject: 'oidc-provisioned-subject',
oidcSubject: `oidc-provisioned-subject-${suffix}`,
oidcIssuer: DEFAULT_ISSUER,
provisioningMethod: 'oidc',
});
@@ -568,9 +613,9 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
where: eq(schema.oidcSessions.userId, callbackBody.user.id),
});
expect(oidcSession).toMatchObject({
oidcSubject: 'oidc-provisioned-subject',
oidcSubject: `oidc-provisioned-subject-${suffix}`,
oidcIssuer: DEFAULT_ISSUER,
oidcSessionId: 'oidc-provisioned-sid',
oidcSessionId: `oidc-provisioned-sid-${suffix}`,
idTokenHint: 'oidc-id-provisioned',
revoked: false,
});
@@ -605,8 +650,8 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
idToken: 'oidc-id-linked',
});
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
sub: 'oidc-linked-subject',
sid: 'oidc-linked-sid',
sub: `oidc-linked-subject-${suffix}`,
sid: `oidc-linked-sid-${suffix}`,
preferred_username: linkedUser.username,
name: 'Linked Local User',
email: linkedUser.email,
@@ -646,7 +691,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
where: eq(schema.users.id, linkedUser.userId),
});
expect(linkedUserAfter).toMatchObject({
oidcSubject: 'oidc-linked-subject',
oidcSubject: `oidc-linked-subject-${suffix}`,
oidcIssuer: DEFAULT_ISSUER,
provisioningMethod: 'local',
});
@@ -675,8 +720,8 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
expect(meBeforeMatchedLogout.statusCode).toBe(200);
ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({
sub: 'oidc-linked-subject',
sid: 'oidc-linked-sid',
sub: `oidc-linked-subject-${suffix}`,
sid: `oidc-linked-sid-${suffix}`,
jti: `logout-linked-${randomUUID()}`,
});
+35 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import { expect, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import { count, eq, inArray, isNull, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
@@ -47,6 +48,7 @@ export function makeMetadataNoopMock(): MetadataNoopMock {
}
async function getAdminToken(app: NestFastifyApplication): Promise<string> {
const db = app.get<Db>(DB);
const setupResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
@@ -60,7 +62,39 @@ async function getAdminToken(app: NestFastifyApplication): Promise<string> {
}
if (setupResponse.statusCode === 409) {
throw new Error('Initial setup already completed. Use the dedicated e2e database reset command before running e2e tests.');
const loginResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: ADMIN_SETUP_DTO.username, password: ADMIN_SETUP_DTO.password },
});
if (loginResponse.statusCode === 200) {
const body = loginResponse.json() as { accessToken?: string };
if (body.accessToken) return body.accessToken;
}
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `scanner-e2e-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 4);
await db.insert(schema.users).values({
username: fallbackUsername,
name: 'Scanner E2E Admin (fallback)',
email: `${fallbackUsername}@example.com`,
passwordHash,
isSuperuser: true,
isDefaultPassword: false,
provisioningMethod: 'local',
});
const fallbackResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: fallbackUsername, password: ADMIN_SETUP_DTO.password },
});
if (fallbackResponse.statusCode !== 200) {
throw new Error(`app-harness: fallback admin login failed (${fallbackResponse.statusCode})`);
}
const body = fallbackResponse.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Fallback login succeeded but accessToken was missing');
return body.accessToken;
}
throw new Error(`Unexpected setup response: ${setupResponse.statusCode} ${setupResponse.body}`);

Some files were not shown because too many files have changed in this diff Show More