mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
Compare commits
8 Commits
dev
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4319460fe7 | ||
|
|
d4c52bbf2f | ||
|
|
87f50bf0cc | ||
|
|
2c66da1b19 | ||
|
|
ab19955502 | ||
|
|
1db9dcec81 | ||
|
|
49c2d3163e | ||
|
|
45b9e13a13 |
10
.github/workflows/cicd.yml
vendored
10
.github/workflows/cicd.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
image: traefik:v3.7
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
- 80:80{{end}}
|
||||
|
||||
traefik:
|
||||
image: docker.io/traefik:v3.6
|
||||
image: docker.io/traefik:v3.7
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||
"policyAuthHeaderAuthSummary": "Header configured",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthHeaderName": "Header name",
|
||||
"policyAuthHeaderValue": "Expected value",
|
||||
"policyAuthSetPasscode": "Set Passcode",
|
||||
"policyAuthSetPincode": "Set PIN Code",
|
||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||
|
||||
@@ -616,8 +616,8 @@ export async function listResources(
|
||||
and(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
or(
|
||||
effectiveSso,
|
||||
effectiveWhitelist,
|
||||
eq(effectiveSso, true),
|
||||
eq(effectiveWhitelist, true),
|
||||
not(isNull(effectiveHeaderAuthId)),
|
||||
not(isNull(effectivePincodeId)),
|
||||
not(isNull(effectivePasswordId))
|
||||
@@ -629,8 +629,8 @@ export async function listResources(
|
||||
conditions.push(
|
||||
and(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
not(effectiveSso),
|
||||
not(effectiveWhitelist),
|
||||
not(eq(effectiveSso, true)),
|
||||
not(eq(effectiveWhitelist, true)),
|
||||
isNull(effectiveHeaderAuthId),
|
||||
isNull(effectivePincodeId),
|
||||
isNull(effectivePasswordId)
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useActionState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
if (!headersValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: t("headersValidationError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
const res = await api
|
||||
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
onValidityChange={
|
||||
setHeadersValid
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading || !headersValid}
|
||||
disabled={saveLoading}
|
||||
form="http-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
|
||||
@@ -2,36 +2,24 @@
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HeadersInputProps {
|
||||
value?: { name: string; value: string }[] | null;
|
||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
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({
|
||||
value = [],
|
||||
onChange,
|
||||
onValidityChange,
|
||||
placeholder = `X-Example-Header: example-value
|
||||
X-Another-Header: another-value`,
|
||||
rows = 4,
|
||||
className
|
||||
}: HeadersInputProps) {
|
||||
const t = useTranslations();
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isUserEditingRef = useRef(false);
|
||||
|
||||
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// Parse newline-separated text into header objects, validating each line
|
||||
// against the same rules enforced by the server. Returns either the
|
||||
// parsed headers or an error message describing the first invalid line.
|
||||
const parseHeaders = (
|
||||
// Convert newline-separated string to header objects array
|
||||
const convertToHeadersArray = (
|
||||
newlineSeparated: string
|
||||
):
|
||||
| { headers: { name: string; value: string }[]; error: null }
|
||||
| { headers: null; error: string } => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
||||
return { headers: [], error: null };
|
||||
}
|
||||
): { name: string; value: string }[] | null => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||
|
||||
const lines = newlineSeparated
|
||||
return newlineSeparated
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const headers: { name: string; value: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
.filter((line) => line.length > 0 && line.includes(":"))
|
||||
.map((line) => {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
if (
|
||||
!validHeaderNamePattern.test(name) ||
|
||||
!validHeaderValuePattern.test(value) ||
|
||||
templatePattern.test(name) ||
|
||||
templatePattern.test(value)
|
||||
) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
// Ensure header name conforms to HTTP header requirements
|
||||
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||
const normalizedName = name
|
||||
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
headers.push({ name, value });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
return { name: normalizedName, value };
|
||||
})
|
||||
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||
};
|
||||
|
||||
// Update internal value when external value changes
|
||||
// But only if the user is not currently editing (textarea not focused)
|
||||
useEffect(() => {
|
||||
if (!isUserEditingRef.current) {
|
||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
setInternalValue(convertToNewlineSeparated(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
|
||||
// Mark that user is actively editing
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
const result = parseHeaders(newValue);
|
||||
// Only update parent if the input is in a valid state
|
||||
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||
|
||||
if (result.error) {
|
||||
// Surface the error and do not touch the last known good value.
|
||||
// Silently dropping the update here (without telling the user)
|
||||
// is what previously let stale data get saved without warning.
|
||||
setError(result.error);
|
||||
onValidityChange?.(false);
|
||||
return;
|
||||
if (newValue.trim() === "") {
|
||||
// Empty input is valid - represents no headers
|
||||
onChange([]);
|
||||
} else {
|
||||
// Check if all non-empty lines are properly formatted (contain ':')
|
||||
const lines = newValue.split("\n");
|
||||
const nonEmptyLines = lines
|
||||
.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 = () => {
|
||||
@@ -134,7 +114,6 @@ X-Another-Header: another-value`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
@@ -145,9 +124,5 @@ X-Another-Header: another-value`,
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user