v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
160
lib/csp-report.js
Normal file
160
lib/csp-report.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CSP_REPORT_POLICY_VERSION = 'compat-v1'
|
||||
export const CSP_REPORT_MAX_BYTES = 32 * 1024
|
||||
export const CSP_REPORT_MAX_ITEMS = 20
|
||||
|
||||
const reportBodySchema = z.object({
|
||||
documentURL: z.string().optional(),
|
||||
documentUri: z.string().optional(),
|
||||
'document-uri': z.string().optional(),
|
||||
blockedURL: z.string().optional(),
|
||||
blockedUri: z.string().optional(),
|
||||
'blocked-uri': z.string().optional(),
|
||||
effectiveDirective: z.string().optional(),
|
||||
'effective-directive': z.string().optional(),
|
||||
violatedDirective: z.string().optional(),
|
||||
'violated-directive': z.string().optional()
|
||||
}).passthrough()
|
||||
|
||||
const legacyReportSchema = z.object({
|
||||
'csp-report': reportBodySchema
|
||||
}).passthrough()
|
||||
|
||||
const reportingApiReportSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
body: reportBodySchema
|
||||
}).passthrough()
|
||||
|
||||
const cspPayloadSchema = z.union([
|
||||
legacyReportSchema,
|
||||
reportingApiReportSchema,
|
||||
z.array(reportingApiReportSchema).min(1).max(CSP_REPORT_MAX_ITEMS)
|
||||
])
|
||||
|
||||
/**
|
||||
* CSP 수집 API가 허용하는 JSON 계열 미디어 타입인지 확인한다.
|
||||
* @param {string} contentType - Content-Type 헤더
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
export const isSupportedCspReportContentType = (contentType) => {
|
||||
const mediaType = String(contentType || '').split(';', 1)[0].trim().toLowerCase()
|
||||
return mediaType === 'application/csp-report'
|
||||
|| mediaType === 'application/reports+json'
|
||||
|| mediaType === 'application/json'
|
||||
|| (mediaType.startsWith('application/') && mediaType.endsWith('+json'))
|
||||
}
|
||||
|
||||
/**
|
||||
* URL에서 쿼리와 fragment를 제외한 문서 경로만 추출한다.
|
||||
* @param {string} value - 문서 URL
|
||||
* @returns {string} 정규화된 문서 경로
|
||||
*/
|
||||
export const normalizeCspDocumentPath = (value) => {
|
||||
try {
|
||||
const url = new URL(String(value || ''), 'https://csp.invalid')
|
||||
const pathname = url.pathname
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.slice(0, 512)
|
||||
return pathname.startsWith('/') ? pathname : '/'
|
||||
} catch {
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 URL에서 경로·쿼리·fragment를 제거하고 origin 수준으로 축약한다.
|
||||
* @param {string} value - 차단 URL
|
||||
* @returns {string} 차단 origin 또는 안전한 분류값
|
||||
*/
|
||||
export const normalizeCspBlockedOrigin = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
|
||||
if (!normalized) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
if (['inline', 'eval', 'self'].includes(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
if (/^(data|blob|filesystem):/.test(normalized)) {
|
||||
return `${normalized.split(':', 1)[0]}:`
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
return ['http:', 'https:'].includes(url.protocol)
|
||||
? url.origin.slice(0, 255)
|
||||
: `${url.protocol}`.slice(0, 32)
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent 원문을 저장하지 않도록 브라우저 계열로 축약한다.
|
||||
* @param {string} userAgent - User-Agent 헤더
|
||||
* @returns {string} 브라우저 계열
|
||||
*/
|
||||
export const classifyCspUserAgentFamily = (userAgent) => {
|
||||
const value = String(userAgent || '')
|
||||
|
||||
if (/Edg\//i.test(value)) {
|
||||
return 'Edge'
|
||||
}
|
||||
if (/Firefox\//i.test(value)) {
|
||||
return 'Firefox'
|
||||
}
|
||||
if (/Chrome\//i.test(value) || /Chromium\//i.test(value)) {
|
||||
return 'Chrome'
|
||||
}
|
||||
if (/Safari\//i.test(value) && /Version\//i.test(value)) {
|
||||
return 'Safari'
|
||||
}
|
||||
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
/**
|
||||
* 지시어 이름을 집계 키로 사용할 수 있는 짧은 값으로 정규화한다.
|
||||
* @param {string} value - CSP 지시어
|
||||
* @returns {string} 정규화된 지시어
|
||||
*/
|
||||
const normalizeDirective = (value) => {
|
||||
const directive = String(value || '').trim().toLowerCase().split(/\s+/, 1)[0]
|
||||
return /^[a-z][a-z0-9-]{0,63}$/.test(directive) ? directive : 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP 보고 payload를 검증하고 개인정보가 최소화된 집계 행으로 변환한다.
|
||||
* @param {unknown} payload - JSON payload
|
||||
* @param {string} userAgent - User-Agent 헤더
|
||||
* @returns {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} 집계 행 목록
|
||||
*/
|
||||
export const parseCspReportPayload = (payload, userAgent) => {
|
||||
const parsed = cspPayloadSchema.safeParse(payload)
|
||||
if (!parsed.success) {
|
||||
throw new Error('INVALID_CSP_REPORT')
|
||||
}
|
||||
|
||||
const reports = Array.isArray(parsed.data) ? parsed.data : [parsed.data]
|
||||
return reports.map((report) => {
|
||||
const body = 'csp-report' in report ? report['csp-report'] : report.body
|
||||
const documentUrl = body.documentURL || body.documentUri || body['document-uri'] || ''
|
||||
const blockedUrl = body.blockedURL || body.blockedUri || body['blocked-uri'] || ''
|
||||
const directive = body.effectiveDirective
|
||||
|| body['effective-directive']
|
||||
|| body.violatedDirective
|
||||
|| body['violated-directive']
|
||||
|| ''
|
||||
|
||||
return {
|
||||
policyVersion: CSP_REPORT_POLICY_VERSION,
|
||||
documentPath: normalizeCspDocumentPath(documentUrl),
|
||||
effectiveDirective: normalizeDirective(directive),
|
||||
blockedOrigin: normalizeCspBlockedOrigin(blockedUrl),
|
||||
userAgentFamily: classifyCspUserAgentFamily(userAgent)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,35 +1,99 @@
|
||||
const DANGEROUS_TAG_NAMES = [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'textarea',
|
||||
'select',
|
||||
'meta',
|
||||
'link',
|
||||
'base'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
|
||||
const ALLOWED_HTML_BLOCK_TAGS = [
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'caption',
|
||||
'code',
|
||||
'col',
|
||||
'colgroup',
|
||||
'dd',
|
||||
'details',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'li',
|
||||
'mark',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'small',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'summary',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'time',
|
||||
'tr',
|
||||
'u',
|
||||
'ul'
|
||||
]
|
||||
|
||||
const dangerousTagPattern = new RegExp(`<\\s*(${DANGEROUS_TAG_NAMES.join('|')})\\b[\\s\\S]*?<\\s*\\/\\s*\\1\\s*>`, 'gi')
|
||||
const dangerousSingleTagPattern = new RegExp(`<\\s*(${DANGEROUS_TAG_NAMES.join('|')})\\b[^>]*\\/?>`, 'gi')
|
||||
const HTML_BLOCK_SANITIZE_OPTIONS = {
|
||||
allowedTags: ALLOWED_HTML_BLOCK_TAGS,
|
||||
allowedAttributes: {
|
||||
'*': ['class', 'title', 'aria-label', 'aria-hidden'],
|
||||
a: ['href', 'target', 'rel'],
|
||||
col: ['span', 'width'],
|
||||
img: ['src', 'alt', 'width', 'height', 'loading'],
|
||||
li: ['value'],
|
||||
ol: ['start', 'type'],
|
||||
td: ['colspan', 'rowspan', 'headers'],
|
||||
th: ['colspan', 'rowspan', 'headers', 'scope'],
|
||||
time: ['datetime']
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
||||
allowProtocolRelative: false,
|
||||
nonTextTags: ['script', 'style', 'textarea', 'option', 'template', 'noscript'],
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => {
|
||||
if (attribs.target !== '_blank') {
|
||||
return { tagName, attribs }
|
||||
}
|
||||
|
||||
const relValues = new Set(String(attribs.rel || '').split(/\s+/).filter(Boolean))
|
||||
relValues.add('noopener')
|
||||
relValues.add('noreferrer')
|
||||
|
||||
return {
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
rel: [...relValues].join(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 HTML 블록에서 실행성 코드와 위험 속성을 제거한다.
|
||||
* @param {string} value - 원본 HTML
|
||||
* @returns {string} 정리된 HTML
|
||||
*/
|
||||
export const sanitizeHtmlBlock = (value) => String(value || '')
|
||||
.replace(dangerousTagPattern, '')
|
||||
.replace(dangerousSingleTagPattern, '')
|
||||
.replace(/\s+on[a-z]+\s*=\s*"[^"]*"/gi, '')
|
||||
.replace(/\s+on[a-z]+\s*=\s*'[^']*'/gi, '')
|
||||
.replace(/\s+on[a-z]+\s*=\s*[^\s>]+/gi, '')
|
||||
.replace(/\s+(href|src)\s*=\s*"javascript:[^"]*"/gi, '')
|
||||
.replace(/\s+(href|src)\s*=\s*'javascript:[^']*'/gi, '')
|
||||
.replace(/\s+(href|src)\s*=\s*javascript:[^\s>]+/gi, '')
|
||||
.replace(/\s+style\s*=\s*"[^"]*expression\s*\([^"]*"/gi, '')
|
||||
.replace(/\s+style\s*=\s*'[^']*expression\s*\([^']*'/gi, '')
|
||||
.trim()
|
||||
export const sanitizeHtmlBlock = (value) => {
|
||||
const html = String(value || '').trim()
|
||||
return html ? sanitizeHtml(html, HTML_BLOCK_SANITIZE_OPTIONS).trim() : ''
|
||||
}
|
||||
|
||||
151
lib/media-catalog.js
Normal file
151
lib/media-catalog.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { basename, extname, posix } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
const mediaUrlSchema = z.string().trim().min(1).max(2048)
|
||||
const imageExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||
const videoExtensions = new Set(['.mp4', '.webm', '.mov'])
|
||||
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a'])
|
||||
|
||||
/**
|
||||
* PostgreSQL LIKE 검색어의 wildcard 문자를 리터럴로 이스케이프한다.
|
||||
* SQL에서는 `ESCAPE '!'`와 함께 사용한다.
|
||||
* @param {string} value - 검색어
|
||||
* @returns {string} LIKE 리터럴
|
||||
*/
|
||||
export const escapePostgresLikeLiteral = (value) => String(value || '')
|
||||
.replace(/!/g, '!!')
|
||||
.replace(/%/g, '!%')
|
||||
.replace(/_/g, '!_')
|
||||
|
||||
/**
|
||||
* 업로드 URL을 카탈로그의 공개 URL과 저장 키로 정규화한다.
|
||||
* @param {unknown} value - 입력 URL
|
||||
* @returns {{ url: string, storageKey: string }} 정규화 결과
|
||||
*/
|
||||
export const normalizeMediaCatalogUrl = (value) => {
|
||||
const input = mediaUrlSchema.parse(value)
|
||||
const pathOnly = input.split(/[?#]/, 1)[0]
|
||||
let decodedPath = ''
|
||||
|
||||
try {
|
||||
decodedPath = decodeURIComponent(pathOnly.replace(/%(?![0-9a-f]{2})/gi, '%25'))
|
||||
} catch {
|
||||
throw new Error('MEDIA_URL_INVALID_ENCODING')
|
||||
}
|
||||
|
||||
if (decodedPath.includes('\0') || decodedPath.includes('\\')) {
|
||||
throw new Error('MEDIA_URL_INVALID_PATH')
|
||||
}
|
||||
|
||||
const normalizedPath = posix.normalize(decodedPath)
|
||||
const rawSegments = decodedPath.split('/')
|
||||
const normalizedSegments = normalizedPath.split('/')
|
||||
|
||||
if (rawSegments.some((segment) => segment === '.' || segment === '..')
|
||||
|| normalizedSegments.some((segment) => segment === '..')
|
||||
|| normalizedPath !== decodedPath.replace(/\/+/g, '/')) {
|
||||
throw new Error('MEDIA_URL_TRAVERSAL')
|
||||
}
|
||||
|
||||
if (!normalizedPath.startsWith('/uploads/') || normalizedPath === '/uploads/') {
|
||||
throw new Error('MEDIA_URL_OUTSIDE_UPLOADS')
|
||||
}
|
||||
|
||||
const storageKey = normalizedPath.slice('/uploads/'.length)
|
||||
|
||||
return {
|
||||
url: `/uploads/${storageKey}`,
|
||||
storageKey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장자에서 미디어 종류를 판별한다.
|
||||
* @param {string} fileName - 파일명
|
||||
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
|
||||
*/
|
||||
export const detectMediaKind = (fileName) => {
|
||||
const extension = extname(String(fileName || '')).toLowerCase()
|
||||
|
||||
if (imageExtensions.has(extension)) {
|
||||
return 'image'
|
||||
}
|
||||
if (videoExtensions.has(extension)) {
|
||||
return 'video'
|
||||
}
|
||||
if (audioExtensions.has(extension)) {
|
||||
return 'audio'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 키에서 미디어 역할과 원본 후보 키를 판별한다.
|
||||
* @param {string} storageKey - 업로드 루트 기준 저장 키
|
||||
* @returns {{ role: string, parentStorageKey: string|null }} 역할 판별 결과
|
||||
*/
|
||||
export const detectMediaRole = (storageKey) => {
|
||||
const key = String(storageKey || '').replace(/^\/+/, '')
|
||||
const fileName = basename(key)
|
||||
const directory = posix.dirname(key)
|
||||
const thumbsDirectory = directory.endsWith('/thumbs')
|
||||
const derivedMatch = fileName.match(/^(.*)-(card|page-cover|body(?:-(?:720|1600|2560))?|lightbox)\.webp$/i)
|
||||
|
||||
if (key.startsWith('exports/')) {
|
||||
return { role: 'export', parentStorageKey: null }
|
||||
}
|
||||
if (key.includes('/members/avatars/') || key.startsWith('members/avatars/')) {
|
||||
return { role: 'avatar', parentStorageKey: null }
|
||||
}
|
||||
if (/^(system|site)\//.test(key) || key.startsWith('tags/')) {
|
||||
return { role: 'system', parentStorageKey: null }
|
||||
}
|
||||
if (thumbsDirectory && derivedMatch) {
|
||||
const originalDirectory = posix.dirname(directory)
|
||||
const parentCandidates = ['.jpg', '.jpeg', '.png', '.webp', '.gif']
|
||||
const parentStorageKey = parentCandidates
|
||||
.map((extension) => posix.join(originalDirectory, `${derivedMatch[1]}${extension}`))[0]
|
||||
const variant = derivedMatch[2].toLowerCase()
|
||||
const role = variant === 'card'
|
||||
? 'card'
|
||||
: variant === 'page-cover'
|
||||
? 'page_cover'
|
||||
: 'body'
|
||||
|
||||
return { role, parentStorageKey }
|
||||
}
|
||||
|
||||
return { role: 'original', parentStorageKey: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 필드에서 로컬 미디어 URL 참조를 중복 없이 추출한다.
|
||||
* @param {Record<string, unknown>} fields - 필드명별 값
|
||||
* @returns {Array<{ mediaUrl: string, referenceField: string }>} 참조 목록
|
||||
*/
|
||||
export const extractMediaReferences = (fields) => {
|
||||
const references = new Map()
|
||||
const mediaPattern = /\/uploads\/[^\s"'<>()[\]{}]+/g
|
||||
|
||||
for (const [referenceField, rawValue] of Object.entries(fields || {})) {
|
||||
if (typeof rawValue !== 'string' || !rawValue.includes('/uploads/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const match of rawValue.matchAll(mediaPattern)) {
|
||||
const candidate = match[0].replace(/[.,;:!?]+$/g, '')
|
||||
|
||||
try {
|
||||
const { url } = normalizeMediaCatalogUrl(candidate)
|
||||
references.set(`${referenceField}\0${url}`, {
|
||||
mediaUrl: url,
|
||||
referenceField
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...references.values()]
|
||||
}
|
||||
@@ -97,6 +97,20 @@ export const getPostBodyThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(i
|
||||
*/
|
||||
export const getPostBodyPreviewThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'body-720')
|
||||
|
||||
/**
|
||||
* 본문 1600 파생 이미지 URL인지 확인한다.
|
||||
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
||||
* @returns {boolean} body-1600이면 true
|
||||
*/
|
||||
export const isPostBody1600ThumbnailUrl = (imageUrl) => /\/thumbs\/[^/?#]+-body-1600\.webp$/i.test(String(imageUrl || ''))
|
||||
|
||||
/**
|
||||
* 본문 720 파생 이미지 URL인지 확인한다.
|
||||
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
||||
* @returns {boolean} body-720이면 true
|
||||
*/
|
||||
export const isPostBody720ThumbnailUrl = (imageUrl) => /\/thumbs\/[^/?#]+-body-720\.webp$/i.test(String(imageUrl || ''))
|
||||
|
||||
/**
|
||||
* 게시물 원본 이미지 URL에 대응하는 라이트박스용 이미지 URL을 만든다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
|
||||
Reference in New Issue
Block a user