mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-11 04:07:43 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
+11
-1
@@ -22,10 +22,20 @@
|
||||
|
||||
let popup: HTMLDivElement | undefined
|
||||
|
||||
function isClickInsidePopup (target: Node): boolean {
|
||||
if (popup !== undefined && popup.contains(target)) return true
|
||||
if (!(target instanceof Element)) return false
|
||||
|
||||
if (target.closest('.tippy-box') !== null) return true
|
||||
if (target.closest('[data-block-editor-blur="true"]') !== null) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function handleClick (event: MouseEvent): void {
|
||||
if (event.target instanceof Node) {
|
||||
const top = $popups.length > 0 && $popups[$popups.length - 1].id === popupId
|
||||
if (top && popup !== undefined && !popup.contains(event.target)) {
|
||||
if (top && !isClickInsidePopup(event.target)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch('close', undefined)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl, escapeTableCell } from '../markdown/escape'
|
||||
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from '../markdown/escape'
|
||||
|
||||
describe('markdown/escape', () => {
|
||||
describe('escapeMarkdownLinkText', () => {
|
||||
@@ -56,30 +56,4 @@ describe('markdown/escape', () => {
|
||||
expect(escapeMarkdownLinkUrl('https://example.com')).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeTableCellPreservingLink', () => {
|
||||
it('escapes plain text pipe for table safety', () => {
|
||||
expect(escapeTableCell('a|b')).toBe('a\\|b')
|
||||
})
|
||||
|
||||
it('preserves markdown link and escapes pipes inside text and URL', () => {
|
||||
const input = '[a|b](http://example.com/x|y)'
|
||||
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y)')
|
||||
})
|
||||
|
||||
it('escapes pipes even when value contains escaped characters', () => {
|
||||
const input = '[a|b](http://example.com/x|y\\z)'
|
||||
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y\\\\z)')
|
||||
})
|
||||
|
||||
it('treats strings that do not end with `)` as plain text', () => {
|
||||
const input = '[a|b](http://example.com/x|y'
|
||||
expect(escapeTableCell(input)).toBe('\\[a\\|b\\](http://example.com/x\\|y')
|
||||
})
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeTableCell(null)).toBe('')
|
||||
expect(escapeTableCell(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,23 +37,3 @@ export function escapeMarkdownLinkUrl (url: string): string {
|
||||
.replace(/\|/g, '\\|')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a markdown table cell while preserving `[text](url)` links.
|
||||
*/
|
||||
export function escapeTableCell (value: unknown): string {
|
||||
const s = value == null ? '' : String(value)
|
||||
|
||||
const sep = s.indexOf('](')
|
||||
const looksLikeMarkdownLink = s.startsWith('[') && sep !== -1 && s.endsWith(')')
|
||||
if (!looksLikeMarkdownLink) {
|
||||
return escapeMarkdownLinkText(s)
|
||||
}
|
||||
|
||||
const rawText = s.slice(1, sep)
|
||||
const rawUrl = s.slice(sep + 2, -1)
|
||||
|
||||
const escapedText = escapeMarkdownLinkText(rawText)
|
||||
const escapedUrl = escapeMarkdownLinkUrl(rawUrl)
|
||||
return `[${escapedText}](${escapedUrl})`
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } f
|
||||
import { formatValue } from '../formatter'
|
||||
import { generateHeaders, loadViewletConfig, buildTableModel } from '../model'
|
||||
import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data'
|
||||
import { escapeTableCell } from './escape'
|
||||
import { escapeMarkdownLinkText } from './escape'
|
||||
import { createMarkdownLink } from './link'
|
||||
|
||||
async function preloadRefLookups (
|
||||
@@ -253,7 +253,7 @@ export async function buildMarkdownTableFromDocs (
|
||||
const linkValue = await createMarkdownLink(hierarchy, card, value)
|
||||
row.push(linkValue)
|
||||
} else {
|
||||
row.push(escapeTableCell(value))
|
||||
row.push(escapeMarkdownLinkText(value == null ? '' : String(value)))
|
||||
}
|
||||
}
|
||||
rows.push(row)
|
||||
@@ -375,7 +375,7 @@ export async function buildRelationshipTableMarkdown (
|
||||
if (isDocumentTitle) {
|
||||
value = await createMarkdownLink(hierarchy, docToUse, value)
|
||||
} else {
|
||||
value = escapeTableCell(value)
|
||||
value = escapeMarkdownLinkText(value == null ? '' : String(value))
|
||||
}
|
||||
|
||||
row[attrIndex] = value
|
||||
|
||||
@@ -36,6 +36,7 @@ function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
if (clipboardData === null) return false
|
||||
|
||||
const pastedText = clipboardData.getData('text/plain')
|
||||
const pastedMarkdown = clipboardData.getData('text/markdown')
|
||||
|
||||
// check if we are in code block
|
||||
const { $from } = view.state.selection
|
||||
@@ -47,12 +48,21 @@ function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
// If the clipboard explicitly provides markdown, prefer it even if other types (e.g. html) exist.
|
||||
const hasMarkdown = pastedMarkdown.trim().length > 0
|
||||
const markdownSource = hasMarkdown ? pastedMarkdown : pastedText
|
||||
|
||||
// Table copies include metadata comment; treat as markdown even when clipboard has rich types.
|
||||
const hasTableMetadata =
|
||||
pastedText.includes('<!-- huly-table-metadata:') || pastedMarkdown.includes('<!-- huly-table-metadata:')
|
||||
|
||||
const isPlainPaste = clipboardData.types.length === 1 && clipboardData.types[0] === 'text/plain'
|
||||
|
||||
if (!isPlainPaste) return false
|
||||
// Keep default paste behavior for rich clipboard formats, unless markdown is explicitly present.
|
||||
if (!hasMarkdown && !hasTableMetadata && !isPlainPaste) return false
|
||||
|
||||
try {
|
||||
const markupNode = cleanUnknownContent(view.state.schema, markdownToMarkup(pastedText))
|
||||
const markupNode = cleanUnknownContent(view.state.schema, markdownToMarkup(markdownSource))
|
||||
if (shouldUseMarkdownOutput(markupNode)) {
|
||||
const content = Node.fromJSON(view.state.schema, markupNode)
|
||||
content.check()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { normalizeEscapedMarkdownLinks } from './tablePaste'
|
||||
|
||||
describe('normalizeEscapedMarkdownLinks', () => {
|
||||
it('normalizes partially escaped markdown links', () => {
|
||||
const input = '| \\[Label\\](https://example.com/a\\|b) |'
|
||||
const output = normalizeEscapedMarkdownLinks(input)
|
||||
expect(output).toBe('| [Label](https://example.com/a|b) |')
|
||||
})
|
||||
|
||||
it('normalizes fully escaped markdown links', () => {
|
||||
const input = '| \\[Label\\]\\(https://example.com/a\\|b\\) |'
|
||||
const output = normalizeEscapedMarkdownLinks(input)
|
||||
expect(output).toBe('| [Label](https://example.com/a|b) |')
|
||||
})
|
||||
|
||||
it('keeps non-link text unchanged', () => {
|
||||
const input = '| plain \\| text |'
|
||||
const output = normalizeEscapedMarkdownLinks(input)
|
||||
expect(output).toBe(input)
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,26 @@ function extractMetadataFromHtmlComments (text: string): { metadata: TableMetada
|
||||
return { metadata: null, cleanedText: text }
|
||||
}
|
||||
|
||||
export function normalizeEscapedMarkdownLinks (markdown: string): string {
|
||||
// Some producers escape markdown links in table cells:
|
||||
// 1) partially escaped: `\\[text\\](url)`
|
||||
// 2) fully escaped: `\\[text\\]\\(url\\)`
|
||||
// Normalize both back to `[text](url)` so markdown parser restores link marks.
|
||||
let result = markdown.replace(/\\\[((?:\\.|[^\\\]])*?)\\\]\(([^)\r\n]+)\)/g, (_m, text, url) => {
|
||||
const unescapedText = String(text).replace(/\\([\\[\]|])/g, '$1')
|
||||
const unescapedUrl = String(url).replace(/\\([\\|)])/g, '$1')
|
||||
return `[${unescapedText}](${unescapedUrl})`
|
||||
})
|
||||
|
||||
result = result.replace(/\\\[((?:\\.|[^\\\]])*?)\\\]\\\(([^)\r\n]+?)\\\)/g, (_m, text, url) => {
|
||||
const unescapedText = String(text).replace(/\\([\\[\]|])/g, '$1')
|
||||
const unescapedUrl = String(url).replace(/\\([\\|)])/g, '$1')
|
||||
return `[${unescapedText}](${unescapedUrl})`
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function TableMetadataPastePlugin (): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
@@ -128,6 +148,8 @@ function TableMetadataPastePlugin (): Plugin {
|
||||
return false
|
||||
}
|
||||
|
||||
markdown = normalizeEscapedMarkdownLinks(markdown)
|
||||
|
||||
// Check if we're in a code block (don't process tables there)
|
||||
const { $from } = view.state.selection
|
||||
for (let d = $from.depth; d > 0; d--) {
|
||||
|
||||
@@ -89,6 +89,18 @@ describe('URL Validation', () => {
|
||||
it('should reject invalid URLs', async () => {
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'not-a-valid-url')).rejects.toThrow(LinkPreviewError)
|
||||
})
|
||||
|
||||
it('should set INVALID_URL code for malformed URLs', async () => {
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'not-a-valid-url')).rejects.toMatchObject({
|
||||
code: 'INVALID_URL'
|
||||
})
|
||||
})
|
||||
|
||||
it('should set INVALID_PROTOCOL code for non-http protocols', async () => {
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'javascript:alert(1)')).rejects.toMatchObject({
|
||||
code: 'INVALID_PROTOCOL'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRF protection', () => {
|
||||
@@ -103,7 +115,13 @@ describe('URL Validation', () => {
|
||||
'192.168.255.255',
|
||||
'169.254.0.1',
|
||||
'0.0.0.0',
|
||||
'localhost'
|
||||
'localhost',
|
||||
'localhost.',
|
||||
'[::1]',
|
||||
// IPv6-mapped IPv4 loopback (hex form used in the report)
|
||||
'[::ffff:7f00:1]',
|
||||
// IPv6-mapped IPv4 loopback (dotted form)
|
||||
'[::ffff:127.0.0.1]'
|
||||
]
|
||||
|
||||
it.each(blockedAddresses)('should block access to %s', async (host) => {
|
||||
@@ -120,6 +138,24 @@ describe('URL Validation', () => {
|
||||
const result = await parseLinkPreviewDetails(ctx, defaultConfig, 'https://8.8.8.8')
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should block unique-local and link-local IPv6 addresses', async () => {
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://[fc00::1]/path')).rejects.toThrow(
|
||||
LinkPreviewError
|
||||
)
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://[fd12::abcd]/path')).rejects.toThrow(
|
||||
LinkPreviewError
|
||||
)
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://[fe80::1]/path')).rejects.toThrow(
|
||||
LinkPreviewError
|
||||
)
|
||||
})
|
||||
|
||||
it('should set BLOCKED_URL code for blocked hosts', async () => {
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://localhost./path')).rejects.toMatchObject({
|
||||
code: 'BLOCKED_URL'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -469,6 +505,50 @@ describe('oEmbed Integration', () => {
|
||||
expect(result.imageWidth).toBe(1920)
|
||||
expect(result.imageHeight).toBe(1080)
|
||||
})
|
||||
|
||||
it('should block SSRF via oEmbed discovery URL (internal destination)', async () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<link type="application/json+oembed" href="http://[::ffff:7f00:1]:7777/oembed-ssrf">
|
||||
<meta property="og:title" content="OG Title">
|
||||
</head>
|
||||
</html>
|
||||
`
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
// Implementation should not rely on automatic redirect following.
|
||||
if (init?.redirect === 'follow') return Promise.reject(new Error('redirect: follow should not be used'))
|
||||
|
||||
if (url === 'https://example.com/attacker') return Promise.resolve(createHtmlResponse(html))
|
||||
// oEmbed fetch must not happen (blocked by URL validation).
|
||||
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
|
||||
})
|
||||
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/attacker')).rejects.toThrow(
|
||||
LinkPreviewError
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to OG when oEmbed request throws network error', async () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<link type="application/json+oembed" href="https://example.com/oembed">
|
||||
<meta property="og:title" content="OG Fallback Title">
|
||||
</head>
|
||||
</html>
|
||||
`
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://example.com/oembed') return Promise.reject(new Error('oEmbed network down'))
|
||||
if (url === 'https://example.com/page') return Promise.resolve(createHtmlResponse(html))
|
||||
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
|
||||
})
|
||||
|
||||
const result = await parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/page')
|
||||
expect(result.title).toBe('OG Fallback Title')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
@@ -568,6 +648,14 @@ describe('Error Handling', () => {
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com')).rejects.toThrow(LinkPreviewError)
|
||||
})
|
||||
|
||||
it('should set FETCH_FAILED code on network errors', async () => {
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com')).rejects.toMatchObject({
|
||||
code: 'FETCH_FAILED'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle malformed HTML gracefully', async () => {
|
||||
const html = '<html><head><title>Broken<<<<</title></head></html>'
|
||||
global.fetch = jest.fn().mockResolvedValue(createHtmlResponse(html))
|
||||
@@ -726,6 +814,72 @@ describe('Edge Cases', () => {
|
||||
expect(result.title).toBe('Redirected')
|
||||
})
|
||||
|
||||
it('should block SSRF via redirects to internal addresses', async () => {
|
||||
global.fetch = jest.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (init?.redirect === 'follow') return Promise.reject(new Error('redirect: follow should not be used'))
|
||||
|
||||
if (url === 'https://example.com/start') {
|
||||
return Promise.resolve(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'http://[::ffff:7f00:1]:9999/' }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Redirect target should never be fetched if validation is correct.
|
||||
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
|
||||
})
|
||||
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')).rejects.toThrow(
|
||||
LinkPreviewError
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep current URL when redirect response has no location', async () => {
|
||||
const html = '<html><head><title>No Location Redirect</title></head></html>'
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://example.com/start') return Promise.resolve(new Response(null, { status: 302 }))
|
||||
return Promise.resolve(createHtmlResponse(html))
|
||||
})
|
||||
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')).rejects.toThrow(
|
||||
LinkPreviewError
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve relative redirect locations', async () => {
|
||||
const html = '<html><head><title>Relative Redirect</title></head></html>'
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://example.com/start') {
|
||||
return Promise.resolve(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: '/next' }
|
||||
})
|
||||
)
|
||||
}
|
||||
if (url === 'https://example.com/next') return Promise.resolve(createHtmlResponse(html))
|
||||
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
|
||||
})
|
||||
|
||||
const result = await parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')
|
||||
expect(result.title).toBe('Relative Redirect')
|
||||
})
|
||||
|
||||
it('should fail after too many redirects', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: '/loop' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')).rejects.toThrow(
|
||||
'Too many redirects'
|
||||
)
|
||||
})
|
||||
|
||||
it('should sanitize HTML entities in titles', async () => {
|
||||
const html = '<html><head><title>Rock & Roll</title></head></html>'
|
||||
global.fetch = jest.fn().mockResolvedValue(createHtmlResponse(html))
|
||||
|
||||
+175
-38
@@ -17,6 +17,7 @@ import { MeasureContext } from '@hcengineering/core'
|
||||
import * as cheerio from 'cheerio'
|
||||
import { imageSize } from 'image-size'
|
||||
import oembedProviders from 'oembed-providers'
|
||||
import net from 'node:net'
|
||||
|
||||
// ============================================================================
|
||||
// Types and Interfaces
|
||||
@@ -89,20 +90,6 @@ const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB
|
||||
const OEMBED_SERVICE_NAME = 'Huly Link Preview Service/1.0'
|
||||
|
||||
// Private IP ranges to block for SSRF protection
|
||||
const BLOCKED_IP_PATTERNS = [
|
||||
/^127\./, // Loopback
|
||||
/^10\./, // Private Class A
|
||||
/^172\.(1[6-9]|2\d|3[01])\./, // Private Class B
|
||||
/^192\.168\./, // Private Class C
|
||||
/^169\.254\./, // Link-local
|
||||
/^0\./, // Current network
|
||||
/^localhost$/i,
|
||||
/^::1$/, // IPv6 loopback
|
||||
/^fc00:/i, // IPv6 private
|
||||
/^fe80:/i // IPv6 link-local
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Error Classes
|
||||
// ============================================================================
|
||||
@@ -110,6 +97,7 @@ const BLOCKED_IP_PATTERNS = [
|
||||
export class LinkPreviewError extends Error {
|
||||
constructor (
|
||||
message: string,
|
||||
public readonly code?: 'BLOCKED_URL' | 'INVALID_URL' | 'INVALID_PROTOCOL' | 'TIMEOUT' | 'FETCH_FAILED',
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message)
|
||||
@@ -121,30 +109,165 @@ export class LinkPreviewError extends Error {
|
||||
// URL Validation
|
||||
// ============================================================================
|
||||
|
||||
function normalizeHostnameForChecks (hostname: string): string {
|
||||
// URL.hostname is already punycode-normalized by WHATWG URL for IDNs.
|
||||
// Keep it lowercase for comparisons.
|
||||
const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, '')
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function parseIpv6MappedIpv4 (ipv6: string): string | undefined {
|
||||
const host = ipv6.toLowerCase()
|
||||
|
||||
// Common form: ::ffff:127.0.0.1
|
||||
const dotted = host.match(/(?:^|:)ffff:(\d{1,3}(?:\.\d{1,3}){3})$/)
|
||||
if (dotted?.[1] !== undefined) return dotted[1]
|
||||
|
||||
// Hex form used in the report: ::ffff:7f00:1 (=> 127.0.0.1)
|
||||
const hex = host.match(/(?:^|:)ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
||||
if (hex?.[1] === undefined || hex?.[2] === undefined) return undefined
|
||||
|
||||
const hi = Number.parseInt(hex[1], 16)
|
||||
const lo = Number.parseInt(hex[2], 16)
|
||||
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return undefined
|
||||
|
||||
const a = (hi >> 8) & 0xff
|
||||
const b = hi & 0xff
|
||||
const c = (lo >> 8) & 0xff
|
||||
const d = lo & 0xff
|
||||
return `${a}.${b}.${c}.${d}`
|
||||
}
|
||||
|
||||
function isBlockedIpv4 (ipv4: string): boolean {
|
||||
const parts = ipv4.split('.').map((p) => Number.parseInt(p, 10))
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return true
|
||||
|
||||
const [a, b] = parts
|
||||
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true
|
||||
// 127.0.0.0/8 loopback
|
||||
if (a === 127) return true
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true
|
||||
// 169.254.0.0/16 link-local
|
||||
if (a === 169 && b === 254) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isBlockedIpv6 (ipv6: string): boolean {
|
||||
const host = ipv6.toLowerCase()
|
||||
// unspecified / loopback (compressed or expanded)
|
||||
if (host === '::' || host === '0:0:0:0:0:0:0:0') return true
|
||||
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true
|
||||
if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7 (coarse but safe)
|
||||
if (
|
||||
host.startsWith('fe80:') ||
|
||||
host.startsWith('fe8') ||
|
||||
host.startsWith('fe9') ||
|
||||
host.startsWith('fea') ||
|
||||
host.startsWith('feb')
|
||||
) {
|
||||
// link-local fe80::/10 (coarse but safe)
|
||||
return true
|
||||
}
|
||||
|
||||
const mapped = parseIpv6MappedIpv4(host)
|
||||
if (mapped !== undefined) return isBlockedIpv4(mapped)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isBlockedHost (hostname: string): boolean {
|
||||
const host = normalizeHostnameForChecks(hostname)
|
||||
if (host === 'localhost') return true
|
||||
|
||||
const ipType = net.isIP(host)
|
||||
if (ipType === 4) return isBlockedIpv4(host)
|
||||
if (ipType === 6) return isBlockedIpv6(host)
|
||||
|
||||
// Some Node versions are stricter about IPv6 parsing. If it still looks like an IPv6 literal,
|
||||
// apply our IPv6 checks anyway (covers IPv6-mapped IPv4 forms like ::ffff:7f00:1).
|
||||
if (host.includes(':') && isBlockedIpv6(host)) return true
|
||||
|
||||
// Hostname is not an IP literal. Keep legacy explicit localhost-ish blocks.
|
||||
// (We intentionally do not attempt DNS resolution here.)
|
||||
if (host.endsWith('.localhost')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function validateUrl (urlString: string): URL {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(urlString)
|
||||
} catch {
|
||||
throw new LinkPreviewError(`Invalid URL: ${urlString}`)
|
||||
throw new LinkPreviewError(`Invalid URL: ${urlString}`, 'INVALID_URL')
|
||||
}
|
||||
|
||||
// Only allow HTTP(S) protocols
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new LinkPreviewError(`Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`)
|
||||
throw new LinkPreviewError(
|
||||
`Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`,
|
||||
'INVALID_PROTOCOL'
|
||||
)
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal IPs
|
||||
const hostname = url.hostname
|
||||
for (const pattern of BLOCKED_IP_PATTERNS) {
|
||||
if (pattern.test(hostname)) {
|
||||
throw new LinkPreviewError('Blocked URL: Access to internal addresses is not allowed.')
|
||||
}
|
||||
// SSRF protection: block private/internal hosts and IP literals (incl. IPv6-mapped IPv4)
|
||||
if (isBlockedHost(url.hostname)) {
|
||||
throw new LinkPreviewError('Blocked URL: Access to internal addresses is not allowed.', 'BLOCKED_URL')
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function isRedirectStatus (status: number): boolean {
|
||||
return status >= 300 && status < 400
|
||||
}
|
||||
|
||||
async function fetchWithValidatedRedirects (
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number,
|
||||
maxRedirects: number = 5
|
||||
): Promise<{ response: Response, finalUrl: string }> {
|
||||
let currentUrl = url
|
||||
|
||||
for (let i = 0; i <= maxRedirects; i++) {
|
||||
// Validate every hop (including the initial request URL).
|
||||
validateUrl(currentUrl)
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
currentUrl,
|
||||
{
|
||||
...options,
|
||||
redirect: 'manual'
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
if (!isRedirectStatus(response.status)) {
|
||||
return { response, finalUrl: currentUrl }
|
||||
}
|
||||
|
||||
const location = response.headers.get('location')
|
||||
if (!isNonEmptyString(location)) {
|
||||
return { response, finalUrl: currentUrl }
|
||||
}
|
||||
|
||||
const nextUrl = new URL(location, currentUrl).href
|
||||
currentUrl = nextUrl
|
||||
}
|
||||
|
||||
throw new LinkPreviewError('Too many redirects')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fetch Utilities
|
||||
// ============================================================================
|
||||
@@ -163,10 +286,11 @@ async function fetchWithTimeout (url: string, options: RequestInit, timeoutMs: n
|
||||
return response
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new LinkPreviewError(`Request timed out after ${timeoutMs}ms`, error)
|
||||
throw new LinkPreviewError(`Request timed out after ${timeoutMs}ms`, 'TIMEOUT', error)
|
||||
}
|
||||
throw new LinkPreviewError(
|
||||
`Failed to fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
'FETCH_FAILED',
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
@@ -230,9 +354,15 @@ async function fetchOEmbedData (
|
||||
|
||||
if (oembedUrl === null) return null
|
||||
|
||||
// Validate discovered/provider oEmbed URL to prevent SSRF.
|
||||
validateUrl(oembedUrl)
|
||||
ctx.info('fetching oEmbed data', { oembedUrl })
|
||||
|
||||
const response = await fetchWithTimeout(oembedUrl, { headers: { 'User-Agent': OEMBED_SERVICE_NAME } }, timeoutMs)
|
||||
const { response } = await fetchWithValidatedRedirects(
|
||||
oembedUrl,
|
||||
{ headers: { 'User-Agent': OEMBED_SERVICE_NAME } },
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
ctx.warn('oEmbed fetch failed', {
|
||||
@@ -259,12 +389,16 @@ async function fetchOEmbedData (
|
||||
ctx.info('successfully fetched oEmbed data', { type: data.type })
|
||||
return data
|
||||
} catch (error) {
|
||||
// Don't throw - oEmbed failure should fall back to OG parsing
|
||||
// Security-related URL validation errors must still fail fast.
|
||||
if (error instanceof LinkPreviewError && error.code === 'BLOCKED_URL') {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Other oEmbed failures should fall back to OG parsing.
|
||||
ctx.warn('failed to fetch oEmbed data', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
// return null
|
||||
throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +558,11 @@ async function loadImageSize (ctx: MeasureContext, url: string, config: Config):
|
||||
// Validate the image URL too
|
||||
validateUrl(url)
|
||||
|
||||
const response = await fetchWithTimeout(url, { headers: { 'User-Agent': config.UserAgent } }, timeoutMs)
|
||||
const { response } = await fetchWithValidatedRedirects(
|
||||
url,
|
||||
{ headers: { 'User-Agent': config.UserAgent } },
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
ctx.warn('failed to fetch image', { status: response.status, url })
|
||||
@@ -598,10 +736,9 @@ export async function parseLinkPreviewDetails (
|
||||
const parsedUrl = validateUrl(query)
|
||||
|
||||
// Fetch the page
|
||||
const response = await fetchWithTimeout(
|
||||
const { response, finalUrl } = await fetchWithValidatedRedirects(
|
||||
query,
|
||||
{
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
'User-Agent': config.UserAgent
|
||||
@@ -617,10 +754,10 @@ export async function parseLinkPreviewDetails (
|
||||
// Check if response is an image
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (contentType.startsWith('image/')) {
|
||||
const size = await loadImageSize(ctx, query, config)
|
||||
const size = await loadImageSize(ctx, finalUrl, config)
|
||||
return {
|
||||
url: query,
|
||||
image: query,
|
||||
url: finalUrl,
|
||||
image: finalUrl,
|
||||
host: `${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
hostname: parsedUrl.hostname,
|
||||
imageWidth: size?.width,
|
||||
@@ -639,17 +776,17 @@ export async function parseLinkPreviewDetails (
|
||||
const host = `${parsedUrl.protocol}//${parsedUrl.host}`
|
||||
|
||||
// Try oEmbed first
|
||||
const oembedData = await fetchOEmbedData(ctx, $, query, timeoutMs)
|
||||
const oembedData = await fetchOEmbedData(ctx, $, finalUrl, timeoutMs)
|
||||
if (oembedData !== null) {
|
||||
const ogSiteName = $('meta[property="og:site_name"]').attr('content')
|
||||
const hostname = isNonEmptyString(ogSiteName) ? ogSiteName : parsedUrl.hostname
|
||||
ctx.info('using oEmbed data', { url: query })
|
||||
return convertOEmbedToPreview(oembedData, query, hostname, host)
|
||||
ctx.info('using oEmbed data', { url: finalUrl })
|
||||
return convertOEmbedToPreview(oembedData, finalUrl, hostname, host)
|
||||
}
|
||||
|
||||
// Fall back to Open Graph / meta tag parsing
|
||||
ctx.info('using Open Graph data', { url: query })
|
||||
const preview = parseOpenGraphData($, config, parsedUrl, query)
|
||||
ctx.info('using Open Graph data', { url: finalUrl })
|
||||
const preview = parseOpenGraphData($, config, parsedUrl, finalUrl)
|
||||
|
||||
// Get image dimensions if we have an image but no dimensions
|
||||
let imageWidth: number | undefined
|
||||
|
||||
@@ -30,25 +30,53 @@ import core, {
|
||||
TxUpdateDoc
|
||||
} from '@hcengineering/core'
|
||||
import process, {
|
||||
ApproveRequest,
|
||||
ContextId,
|
||||
Execution,
|
||||
ExecutionStatus,
|
||||
isUpdateTx,
|
||||
Method,
|
||||
parseContext,
|
||||
Process,
|
||||
ProcessContext,
|
||||
ProcessCustomEvent,
|
||||
ProcessToDo,
|
||||
SelectedExecutionContext,
|
||||
State,
|
||||
Step,
|
||||
Transition,
|
||||
isUpdateTx,
|
||||
ProcessCustomEvent,
|
||||
ApproveRequest,
|
||||
ExecutionStatus,
|
||||
Trigger
|
||||
} from '@hcengineering/process'
|
||||
import { QueueTopic, TriggerControl } from '@hcengineering/server-core'
|
||||
import { ProcessMessage } from '@hcengineering/server-process'
|
||||
import {
|
||||
AddRelation,
|
||||
AddTag,
|
||||
ApproveRequestApproved,
|
||||
ApproveRequestRejected,
|
||||
CancelSubProcess,
|
||||
CancelToDo,
|
||||
CheckSubProcessesDone,
|
||||
CheckSubProcessMatch,
|
||||
CheckTime,
|
||||
CheckToDoCancelled,
|
||||
CheckToDoDone,
|
||||
CreateCard,
|
||||
CreateToDo,
|
||||
EventCheck,
|
||||
FieldChangedCheck,
|
||||
LockCard,
|
||||
LockField,
|
||||
LockSection,
|
||||
MatchCardCheck,
|
||||
RequestApproval,
|
||||
RunSubProcess,
|
||||
UnlockCard,
|
||||
UnlockField,
|
||||
UnlockSection,
|
||||
UpdateCard
|
||||
} from './functions'
|
||||
import { FieldChangedRollback, ToDoCancellRollback, ToDoCloseRollback } from './rollback'
|
||||
import {
|
||||
Absolute,
|
||||
Add,
|
||||
@@ -58,7 +86,16 @@ import {
|
||||
CurrentDate,
|
||||
CurrentUser,
|
||||
Cut,
|
||||
DateDifference,
|
||||
DateFromNumber,
|
||||
DateFromString,
|
||||
DayFromDate,
|
||||
Divide,
|
||||
EmptyArray,
|
||||
ExecutionInitiator,
|
||||
ExecutionStarted,
|
||||
Filter,
|
||||
FirstMatchValue,
|
||||
FirstValue,
|
||||
FirstWorkingDayAfter,
|
||||
Floor,
|
||||
@@ -66,10 +103,12 @@ import {
|
||||
LastValue,
|
||||
LowerCase,
|
||||
Modulo,
|
||||
MonthFromDate,
|
||||
Multiply,
|
||||
NumberFromDate,
|
||||
NumberFromString,
|
||||
Offset,
|
||||
Power,
|
||||
Sqrt,
|
||||
Prepend,
|
||||
Random,
|
||||
Remove,
|
||||
@@ -80,54 +119,15 @@ import {
|
||||
RoleContext,
|
||||
Round,
|
||||
Split,
|
||||
Sqrt,
|
||||
StringFromBoolean,
|
||||
StringFromDate,
|
||||
StringFromNumber,
|
||||
Subtract,
|
||||
Trim,
|
||||
UpperCase,
|
||||
EmptyArray,
|
||||
ExecutionInitiator,
|
||||
ExecutionStarted,
|
||||
FirstMatchValue,
|
||||
Filter,
|
||||
StringFromNumber,
|
||||
StringFromDate,
|
||||
StringFromBoolean,
|
||||
NumberFromDate,
|
||||
DateFromNumber,
|
||||
NumberFromString,
|
||||
DateFromString,
|
||||
YearFromDate,
|
||||
MonthFromDate,
|
||||
DayFromDate,
|
||||
DateDifference
|
||||
YearFromDate
|
||||
} from './transform'
|
||||
import {
|
||||
RunSubProcess,
|
||||
CancelSubProcess,
|
||||
CreateToDo,
|
||||
UpdateCard,
|
||||
CreateCard,
|
||||
AddRelation,
|
||||
AddTag,
|
||||
CheckToDoDone,
|
||||
CheckToDoCancelled,
|
||||
MatchCardCheck,
|
||||
CheckSubProcessesDone,
|
||||
CheckSubProcessMatch,
|
||||
CheckTime,
|
||||
FieldChangedCheck,
|
||||
EventCheck,
|
||||
RequestApproval,
|
||||
ApproveRequestApproved,
|
||||
ApproveRequestRejected,
|
||||
CancelToDo,
|
||||
LockCard,
|
||||
LockSection,
|
||||
UnlockCard,
|
||||
UnlockSection,
|
||||
LockField,
|
||||
UnlockField
|
||||
} from './functions'
|
||||
import { FieldChangedRollback, ToDoCancellRollback, ToDoCloseRollback } from './rollback'
|
||||
|
||||
async function putEventToQueue (value: Omit<ProcessMessage, 'account'>, control: TriggerControl): Promise<void> {
|
||||
if (control.queue === undefined) return
|
||||
@@ -374,6 +374,7 @@ async function reassignToDos (card: Card, ops: DocumentUpdate<Card>, control: Tr
|
||||
doneOn: null,
|
||||
field: { $ne: null }
|
||||
} as any)
|
||||
const cache = new Map<Ref<Execution>, Execution>()
|
||||
const handledGroups = new Set<string>()
|
||||
for (const todo of todos as any[]) {
|
||||
if (todo.field === undefined || !TxProcessor.hasUpdate(ops, todo.field)) continue
|
||||
@@ -383,7 +384,18 @@ async function reassignToDos (card: Card, ops: DocumentUpdate<Card>, control: Tr
|
||||
if (handledGroups.has(request.group)) continue
|
||||
handledGroups.add(request.group)
|
||||
|
||||
const newUsers = (card[todo.field as keyof Card] as any[]) ?? []
|
||||
const execution =
|
||||
cache.get(todo.execution) ??
|
||||
(await control.findAll(control.ctx, process.class.Execution, { _id: todo.execution }, { limit: 1 }))[0]
|
||||
if (execution === undefined) continue
|
||||
cache.set(todo.execution, execution)
|
||||
const _process = control.modelDb.findObject(execution.process)
|
||||
if (_process === undefined) continue
|
||||
const h = control.hierarchy
|
||||
|
||||
const target = h.isMixin(_process.masterTag) ? h.asIf(card, _process.masterTag) : card
|
||||
if (target === undefined) continue
|
||||
const newUsers = (target[todo.field as keyof Card] as any[]) ?? []
|
||||
if (newUsers.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -173,7 +173,9 @@ export const main = async (): Promise<void> => {
|
||||
res.status(400).send({ err: "'event' or 'workspace' or 'type' is missing" })
|
||||
return
|
||||
}
|
||||
void OutcomingClient.push(ctx, accountClient, workspace, event, type)
|
||||
void OutcomingClient.push(ctx, accountClient, workspace, event, type).catch((err: any) => {
|
||||
ctx.error('Outcoming sync failed', { eventId: event.eventId, workspace, type, error: err.message })
|
||||
})
|
||||
res.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +153,20 @@ export class OutcomingClient {
|
||||
const calendarId = calendar.externalId
|
||||
if (calendarId !== undefined) {
|
||||
await this.rateLimiter.take(1)
|
||||
await this.calendar.events.insert({
|
||||
calendarId,
|
||||
requestBody: body
|
||||
})
|
||||
try {
|
||||
await this.calendar.events.insert({
|
||||
calendarId,
|
||||
requestBody: body
|
||||
})
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Google API insert error', {
|
||||
calendarId,
|
||||
eventId: event.eventId,
|
||||
error: err.message,
|
||||
code: err.code
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,17 +390,33 @@ export class OutcomingClient {
|
||||
}
|
||||
|
||||
private async remove (eventId: string, calendarId: string): Promise<void> {
|
||||
const current = await this.calendar.events.get({ calendarId, eventId })
|
||||
if (current?.data !== undefined) {
|
||||
if (current.data.organizer?.self === true) {
|
||||
await this.rateLimiter.take(1)
|
||||
try {
|
||||
await this.calendar.events.delete({
|
||||
eventId,
|
||||
calendarId
|
||||
})
|
||||
} catch {}
|
||||
try {
|
||||
const current = await this.calendar.events.get({ calendarId, eventId })
|
||||
if (current?.data !== undefined) {
|
||||
if (current.data.organizer?.self === true) {
|
||||
await this.rateLimiter.take(1)
|
||||
try {
|
||||
await this.calendar.events.delete({
|
||||
eventId,
|
||||
calendarId
|
||||
})
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Google API delete error', {
|
||||
calendarId,
|
||||
eventId,
|
||||
error: err.message,
|
||||
code: err.code
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Failed to get event for deletion', {
|
||||
calendarId,
|
||||
eventId,
|
||||
error: err.message,
|
||||
code: err.code
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,15 @@ import {
|
||||
githubExternalSyncVersion,
|
||||
githubSyncVersion
|
||||
} from '../types'
|
||||
import { collectUpdate, deleteObjects, ensureGraphQLOctokit, errorToObj, getSince, isGHWriteAllowed } from './utils'
|
||||
import {
|
||||
collectUpdate,
|
||||
deleteObjects,
|
||||
ensureGraphQLOctokit,
|
||||
ensureRESTOctokit,
|
||||
errorToObj,
|
||||
getSince,
|
||||
isGHWriteAllowed
|
||||
} from './utils'
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { IssueComment, IssueCommentCreatedEvent, IssueCommentEvent } from '@octokit/webhooks-types'
|
||||
@@ -353,7 +361,10 @@ export class CommentSyncManager implements DocSyncManager {
|
||||
|
||||
if (Object.keys(platformUpdate).length > 0) {
|
||||
// Check and update body with external
|
||||
const okit = (await this.provider.getOctokit(ctx, existing.modifiedBy)) ?? container.container.octokit
|
||||
const okit = ensureRESTOctokit(
|
||||
(await this.provider.getOctokit(ctx, existing.modifiedBy)) ?? container.container.octokit,
|
||||
container
|
||||
)
|
||||
const mdown = await this.provider.getMarkdown(existingComment.message)
|
||||
if (mdown.trim().length > 0) {
|
||||
await okit.rest.issues.updateComment({
|
||||
@@ -426,7 +437,10 @@ export class CommentSyncManager implements DocSyncManager {
|
||||
return {}
|
||||
}
|
||||
const chatMessage = existing as ChatMessage
|
||||
const okit = (await this.provider.getOctokit(ctx, chatMessage.modifiedBy)) ?? container.container.octokit
|
||||
const okit = ensureRESTOctokit(
|
||||
(await this.provider.getOctokit(ctx, chatMessage.modifiedBy)) ?? container.container.octokit,
|
||||
container
|
||||
)
|
||||
|
||||
// No external version yet, create it.
|
||||
try {
|
||||
|
||||
@@ -50,6 +50,17 @@ export function ensureGraphQLOctokit (okit: Octokit | undefined, container: Cont
|
||||
return container.container.octokit
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an Octokit instance has the REST API available.
|
||||
* If the provided okit doesn't have rest.issues, falls back to container.octokit which is guaranteed to have it.
|
||||
*/
|
||||
export function ensureRESTOctokit (okit: Octokit | undefined, container: ContainerFocus): Octokit {
|
||||
if (okit !== undefined && typeof (okit as any).rest?.issues?.createComment === 'function') {
|
||||
return okit
|
||||
}
|
||||
return container.container.octokit
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user