Compare commits

..

3 Commits

Author SHA1 Message Date
Owen
2c3151da9b Fix #3374 2026-07-03 17:36:06 -04:00
Owen
f60b8795ad Fix #3383 2026-07-03 17:26:49 -04:00
Owen
4054976388 Fix #3395 2026-07-03 17:15:48 -04:00
7 changed files with 105 additions and 67 deletions

View File

@@ -77,7 +77,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -264,7 +264,7 @@ jobs:
shell: bash shell: bash
- name: Install Go - name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
go-version: 1.25 go-version: 1.25
@@ -407,7 +407,7 @@ jobs:
shell: bash shell: bash
- name: Login to GitHub Container Registry (for cosign) - name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode
traefik: traefik:
image: traefik:v3.7 image: traefik:v3.6
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service

View File

@@ -50,7 +50,7 @@ services:
- 80:80{{end}} - 80:80{{end}}
traefik: traefik:
image: docker.io/traefik:v3.7 image: docker.io/traefik:v3.6
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}

View File

@@ -864,8 +864,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Header name", "policyAuthHeaderName": "Username",
"policyAuthHeaderValue": "Expected value", "policyAuthHeaderValue": "Password",
"policyAuthSetPasscode": "Set Passcode", "policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code", "policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist", "policyAuthSetEmailWhitelist": "Set Email Whitelist",

View File

@@ -616,8 +616,8 @@ export async function listResources(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
or( or(
eq(effectiveSso, true), effectiveSso,
eq(effectiveWhitelist, true), effectiveWhitelist,
not(isNull(effectiveHeaderAuthId)), not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)), not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId)) not(isNull(effectivePasswordId))
@@ -629,8 +629,8 @@ export async function listResources(
conditions.push( conditions.push(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
not(eq(effectiveSso, true)), not(effectiveSso),
not(eq(effectiveWhitelist, true)), not(effectiveWhitelist),
isNull(effectiveHeaderAuthId), isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId), isNull(effectivePincodeId),
isNull(effectivePasswordId) isNull(effectivePasswordId)

View File

@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState } from "react"; import { useActionState, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -137,11 +137,21 @@ function ProxyResourceHttpForm({
}); });
const [, formAction, saveLoading] = useActionState(onSubmit, null); const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() { async function onSubmit() {
const isValid = await form.trigger(); const isValid = await form.trigger();
if (!isValid) return; if (!isValid) return;
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: t("headersValidationError")
});
return;
}
const data = form.getValues(); const data = form.getValues();
const res = await api const res = await api
@@ -318,6 +328,9 @@ function ProxyResourceHttpForm({
onChange={ onChange={
field.onChange field.onChange
} }
onValidityChange={
setHeadersValid
}
rows={4} rows={4}
/> />
</FormControl> </FormControl>
@@ -341,7 +354,7 @@ function ProxyResourceHttpForm({
<Button <Button
type="submit" type="submit"
loading={saveLoading} loading={saveLoading}
disabled={saveLoading} disabled={saveLoading || !headersValid}
form="http-settings-form" form="http-settings-form"
> >
{t("saveSettings")} {t("saveSettings")}

View File

@@ -2,24 +2,36 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps { interface HeadersInputProps {
value?: { name: string; value: string }[] | null; value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void; onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
className?: string; className?: string;
} }
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({ export function HeadersInput({
value = [], value = [],
onChange, onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`, X-Another-Header: another-value`,
rows = 4, rows = 4,
className className
}: HeadersInputProps) { }: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState(""); const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false); const isUserEditingRef = useRef(false);
@@ -34,37 +46,56 @@ X-Another-Header: another-value`,
.join("\n"); .join("\n");
}; };
// Convert newline-separated string to header objects array // Parse newline-separated text into header objects, validating each line
const convertToHeadersArray = ( // against the same rules enforced by the server. Returns either the
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
newlineSeparated: string newlineSeparated: string
): { name: string; value: string }[] | null => { ):
if (!newlineSeparated || newlineSeparated.trim() === "") return []; | { headers: { name: string; value: string }[]; error: null }
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
return newlineSeparated const lines = newlineSeparated
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0 && line.includes(":")) .filter((line) => line.length > 0);
.map((line) => {
const headers: { name: string; value: string }[] = [];
for (const line of lines) {
const colonIndex = line.indexOf(":"); const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim(); const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim(); const value = line.substring(colonIndex + 1).trim();
// Ensure header name conforms to HTTP header requirements if (
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens !validHeaderNamePattern.test(name) ||
const normalizedName = name !validHeaderValuePattern.test(value) ||
.replace(/[^a-zA-Z0-9\-]/g, "") templatePattern.test(name) ||
.toLowerCase(); templatePattern.test(value)
) {
return { headers: null, error: t("headersValidationError") };
}
return { name: normalizedName, value }; headers.push({ name, value });
}) }
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
return { headers, error: null };
}; };
// Update internal value when external value changes // Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused) // But only if the user is not currently editing (textarea not focused)
useEffect(() => { useEffect(() => {
if (!isUserEditingRef.current) { if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value)); setInternalValue(convertToNewlineSeparated(value ?? []));
setError(null);
onValidityChange?.(true);
} }
}, [value]); }, [value]);
@@ -75,31 +106,20 @@ X-Another-Header: another-value`,
// Mark that user is actively editing // Mark that user is actively editing
isUserEditingRef.current = true; isUserEditingRef.current = true;
// Only update parent if the input is in a valid state const result = parseHeaders(newValue);
// Valid states: empty/whitespace only, or contains properly formatted headers
if (newValue.trim() === "") { if (result.error) {
// Empty input is valid - represents no headers // Surface the error and do not touch the last known good value.
onChange([]); // Silently dropping the update here (without telling the user)
} else { // is what previously let stale data get saved without warning.
// Check if all non-empty lines are properly formatted (contain ':') setError(result.error);
const lines = newValue.split("\n"); onValidityChange?.(false);
const nonEmptyLines = lines return;
.map((line) => line.trim())
.filter((line) => line.length > 0);
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
const isValid =
nonEmptyLines.length === 0 ||
nonEmptyLines.every((line) => line.includes(":"));
if (isValid) {
// Safe to convert and update parent
const headersArray = convertToHeadersArray(newValue);
onChange(headersArray);
}
// If not valid, don't call onChange - let user continue typing
} }
setError(null);
onValidityChange?.(true);
onChange(result.headers);
}; };
const handleFocus = () => { const handleFocus = () => {
@@ -114,6 +134,7 @@ X-Another-Header: another-value`,
}; };
return ( return (
<div>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={internalValue} value={internalValue}
@@ -124,5 +145,9 @@ X-Another-Header: another-value`,
rows={rows} rows={rows}
className={className} className={className}
/> />
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
); );
} }