diff --git a/messages/en-US.json b/messages/en-US.json index 848f4940..48442a1a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1505,5 +1505,8 @@ "internationaldomaindetected": "International Domain Detected", "willbestoredas": "Will be stored as:", "idpGoogleDescription": "Google OAuth2/OIDC provider", - "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider" -} + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "customHeaders": "Custom Headers", + "customHeadersDescription": "Headers new line separated: Header-Name: value", + "headersValidationError": "Headers must be in the format: Header-Name: value" +} \ No newline at end of file diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 6c581e47..522e5018 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean { return true; } +export function validateHeaders(headers: string): boolean { + // Validate comma-separated headers in format "Header-Name: value" + const headerPairs = headers.split(",").map((pair) => pair.trim()); + return headerPairs.every((pair) => { + // Check if the pair contains exactly one colon + const colonCount = (pair.match(/:/g) || []).length; + if (colonCount !== 1) { + return false; + } + + const colonIndex = pair.indexOf(":"); + if (colonIndex === 0 || colonIndex === pair.length - 1) { + return false; + } + + const headerName = pair.substring(0, colonIndex).trim(); + const headerValue = pair.substring(colonIndex + 1).trim(); + + // Header name should not be empty and should contain valid characters + // Header names are case-insensitive and can contain alphanumeric, hyphens + const headerNameRegex = /^[a-zA-Z0-9\-_]+$/; + if (!headerName || !headerNameRegex.test(headerName)) { + return false; + } + + // Header value should not be empty and should not contain colons + if (!headerValue || headerValue.includes(":")) { + return false; + } + + return true; + }); +} + const validTlds = [ "AAA", "AARP", diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 05f1cf66..ab2b05b3 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -21,6 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateHeaders } from "@server/lib/validators"; const updateResourceParamsSchema = z .object({ @@ -45,7 +46,8 @@ const updateHttpResourceBodySchema = z stickySession: z.boolean().optional(), tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), - skipToIdpId: z.number().int().positive().nullable().optional() + skipToIdpId: z.number().int().positive().nullable().optional(), + headers: z.string().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -83,6 +85,18 @@ const updateHttpResourceBodySchema = z message: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } + ) + .refine( + (data) => { + if (data.headers) { + return validateHeaders(data.headers) + } + return true; + }, + { + message: + "Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons." + } ); export type UpdateResourceResponse = Resource; diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 27c795ef..2c98b07a 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -95,6 +95,7 @@ import { } from "@app/components/ui/command"; import { Badge } from "@app/components/ui/badge"; import { parseHostTarget } from "@app/lib/parseHostTarget"; +import { HeadersInput } from "@app/components/HeadersInput"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), @@ -129,7 +130,9 @@ export default function ReverseProxyTargets(props: { const [targets, setTargets] = useState([]); const [targetsToRemove, setTargetsToRemove] = useState([]); const [sites, setSites] = useState([]); - const [dockerStates, setDockerStates] = useState>(new Map()); + const [dockerStates, setDockerStates] = useState>( + new Map() + ); const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { @@ -139,14 +142,14 @@ export default function ReverseProxyTargets(props: { const dockerManager = new DockerManager(api, siteId); const dockerState = await dockerManager.initializeDocker(); - setDockerStates(prev => new Map(prev.set(siteId, dockerState))); + setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); }; const refreshContainersForSite = async (siteId: number) => { const dockerManager = new DockerManager(api, siteId); const containers = await dockerManager.fetchContainers(); - setDockerStates(prev => { + setDockerStates((prev) => { const newMap = new Map(prev); const existingState = newMap.get(siteId); if (existingState) { @@ -157,11 +160,13 @@ export default function ReverseProxyTargets(props: { }; const getDockerStateForSite = (siteId: number): DockerState => { - return dockerStates.get(siteId) || { - isEnabled: false, - isAvailable: false, - containers: [] - }; + return ( + dockerStates.get(siteId) || { + isEnabled: false, + isAvailable: false, + containers: [] + } + ); }; const [httpsTlsLoading, setHttpsTlsLoading] = useState(false); @@ -185,7 +190,8 @@ export default function ReverseProxyTargets(props: { { message: t("proxyErrorInvalidHeader") } - ) + ), + headers: z.string().optional() }); const tlsSettingsSchema = z.object({ @@ -241,7 +247,8 @@ export default function ReverseProxyTargets(props: { const proxySettingsForm = useForm({ resolver: zodResolver(proxySettingsSchema), defaultValues: { - setHostHeader: resource.setHostHeader || "" + setHostHeader: resource.setHostHeader || "", + headers: resource.headers || "" } }); @@ -298,7 +305,9 @@ export default function ReverseProxyTargets(props: { setSites(res.data.data.sites); // Initialize Docker for newt sites - const newtSites = res.data.data.sites.filter(site => site.type === "newt"); + const newtSites = res.data.data.sites.filter( + (site) => site.type === "newt" + ); for (const site of newtSites) { initializeDockerForSite(site.siteId); } @@ -418,11 +427,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -471,7 +480,8 @@ export default function ReverseProxyTargets(props: { stickySession: stickySessionData.stickySession, ssl: tlsData.ssl, tlsServerName: tlsData.tlsServerName || null, - setHostHeader: proxyData.setHostHeader || null + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null }; // Single API call to update all settings @@ -483,7 +493,8 @@ export default function ReverseProxyTargets(props: { stickySession: stickySessionData.stickySession, ssl: tlsData.ssl, tlsServerName: tlsData.tlsServerName || null, - setHostHeader: proxyData.setHostHeader || null + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null }); } @@ -546,7 +557,7 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -597,49 +608,59 @@ export default function ReverseProxyTargets(props: { - {selectedSite && selectedSite.type === "newt" && (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })()} + {selectedSite && + selectedSite.type === "newt" && + (() => { + const dockerState = getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })()} ); } }, ...(resource.http ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -658,9 +679,13 @@ export default function ReverseProxyTargets(props: { if (parsed) { updateTarget(row.original.targetId, { ...row.original, - method: hasProtocol ? parsed.protocol : row.original.method, + method: hasProtocol + ? parsed.protocol + : row.original.method, ip: parsed.host, - port: hasPort ? parsed.port : row.original.port + port: hasPort + ? parsed.port + : row.original.port }); } else { updateTarget(row.original.targetId, { @@ -807,21 +832,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -887,18 +912,35 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" + ? (() => { + const dockerState = + getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })() + : null; })()} @@ -964,25 +1006,59 @@ export default function ReverseProxyTargets(props: { name="ip" render={({ field }) => ( - {t("targetAddr")} + + {t("targetAddr")} + { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); + const input = + e.target.value.trim(); + const hasProtocol = + /^(https?|h2c):\/\//.test( + input + ); + const hasPort = + /:\d+(?:\/|$)/.test( + input + ); - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); + if ( + hasProtocol || + hasPort + ) { + const parsed = + parseHostTarget( + input + ); if (parsed) { - if (hasProtocol || !addTargetForm.getValues("method")) { - addTargetForm.setValue("method", parsed.protocol); + if ( + hasProtocol || + !addTargetForm.getValues( + "method" + ) + ) { + addTargetForm.setValue( + "method", + parsed.protocol + ); } - addTargetForm.setValue("ip", parsed.host); - if (hasPort || !addTargetForm.getValues("port")) { - addTargetForm.setValue("port", parsed.port); + addTargetForm.setValue( + "ip", + parsed.host + ); + if ( + hasPort || + !addTargetForm.getValues( + "port" + ) + ) { + addTargetForm.setValue( + "port", + parsed.port + ); } } } else { @@ -1091,12 +1167,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1256,6 +1332,36 @@ export default function ReverseProxyTargets(props: { )} /> + ( + + + {t("customHeaders")} + + + { + field.onChange( + value + ); + }} + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> diff --git a/src/components/HeadersInput.tsx b/src/components/HeadersInput.tsx new file mode 100644 index 00000000..35f3e587 --- /dev/null +++ b/src/components/HeadersInput.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; + +interface HeadersInputProps { + value?: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; + className?: string; +} + +export function HeadersInput({ + value = "", + onChange, + placeholder = `X-Example-Header: example-value +X-Another-Header: another-value`, + rows = 4, + className +}: HeadersInputProps) { + const [internalValue, setInternalValue] = useState(""); + + // Convert comma-separated to newline-separated for display + const convertToNewlineSeparated = (commaSeparated: string): string => { + if (!commaSeparated || commaSeparated.trim() === "") return ""; + + return commaSeparated + .split(',') + .map(header => header.trim()) + .filter(header => header.length > 0) + .join('\n'); + }; + + // Convert newline-separated to comma-separated for output + const convertToCommaSeparated = (newlineSeparated: string): string => { + if (!newlineSeparated || newlineSeparated.trim() === "") return ""; + + return newlineSeparated + .split('\n') + .map(header => header.trim()) + .filter(header => header.length > 0) + .join(', '); + }; + + // Update internal value when external value changes + useEffect(() => { + setInternalValue(convertToNewlineSeparated(value)); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInternalValue(newValue); + + // Convert back to comma-separated format for the parent + const commaSeparatedValue = convertToCommaSeparated(newValue); + onChange(commaSeparatedValue); + }; + + return ( +