This commit is contained in:
jarek committed 2026-06-27 15:15:54 +02:00
1 parent ce12d7ae9f
commit 685132ff89
63 files changed
+1324 -281

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
/**
* Safe cookie header parsing for WebSocket upgrades.
*
* Extracted from ws-auth so it can be unit-tested without pulling in the
* DB/SvelteKit graph (#1224).
*/
export function safeDecode(v: string): string {
// Browsers freely send cookies with stray `%` from third-party trackers and
// legacy code. decodeURIComponent throws URIError on `%` not followed by two
// hex digits, which would crash the entire WS upgrade — including the
// session cookie we actually care about. Fall back to the raw value (#1224).
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
export function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = safeDecode(v);
}
return out;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Pure date/time formatting helpers — no SvelteKit / store imports.
*
* settings.ts wraps these with cached preferences and a reactive subscription;
* keeping the implementation here lets the formatters be unit-tested without
* dragging in $app/environment or svelte/store.
*/
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export interface DateTimeFormatters {
date: Intl.DateTimeFormat;
time: Intl.DateTimeFormat;
}
// Intl.DateTimeFormat construction is expensive; the caller caches one pair per
// timezone and rebuilds only when the timezone setting changes.
export function buildFormatters(timeZone: string | undefined): DateTimeFormatters {
const tz = timeZone || undefined;
return {
date: new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}),
time: new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
};
}
function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? '';
}
export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string {
const parts = dateFormatter.formatToParts(d);
const day = pickPart(parts, 'day');
const month = pickPart(parts, 'month');
const year = pickPart(parts, 'year');
switch (dateFormat) {
case 'MM/DD/YYYY':
return `${month}/${day}/${year}`;
case 'DD/MM/YYYY':
return `${day}/${month}/${year}`;
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`;
case 'DD.MM.YYYY':
default:
return `${day}.${month}.${year}`;
}
}
export function formatTimePartWith(
d: Date,
timeFormatter: Intl.DateTimeFormat,
timeFormat: TimeFormat,
includeSeconds = false
): string {
const parts = timeFormatter.formatToParts(d);
const hours = parseInt(pickPart(parts, 'hour'), 10);
const minutes = pickPart(parts, 'minute');
const seconds = pickPart(parts, 'second');
if (timeFormat === '12h') {
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
return includeSeconds
? `${hour12}:${minutes}:${seconds} ${ampm}`
: `${hour12}:${minutes} ${ampm}`;
} else {
const hour24 = hours.toString().padStart(2, '0');
return includeSeconds
? `${hour24}:${minutes}:${seconds}`
: `${hour24}:${minutes}`;
}
}
+5 -3
View File
@@ -11,12 +11,14 @@
* cause breakage are rejected.
*/
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ '
// First char: any allowed except space (no leading whitespace).
// Last char: any allowed except space and dot (no trailing whitespace,
// no trailing dot to avoid Windows-style "hidden" trailing-dot issues).
// Length: 1..64.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/;
// Apostrophe (#1228): safe — env.name only lands in path.join() and
// array-form spawn(), never in a shell-interpolated string.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-()' +@](?:[A-Za-z0-9_.\-()' +@]{0,62}[A-Za-z0-9_\-)'+@])?$/;
export const ENV_NAME_MAX_LENGTH = 64;
@@ -39,7 +41,7 @@ export function validateEnvName(name: unknown): ValidationResult {
return {
ok: false,
reason:
'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)'
'Name can\'t contain slashes, wildcards, or other shell-special characters, and can\'t start with whitespace or end with whitespace or a dot'
};
}
return { ok: true };
+3
View File
@@ -23,6 +23,9 @@
*
* dockhand.url labels override this — Pangolin extraction is a fallback,
* never a winner over an explicit user-provided URL.
*
* Earlier v1.0.34 used `pangolin.proxy-resources.*` — that label was never
* recognised by Pangolin itself and is no longer accepted here.
*/
export interface PangolinUrl {
url: string;
+22
View File
@@ -0,0 +1,22 @@
/**
* Path-relative redirect target validation.
*
* Returns true only for targets that are path-relative URLs ('/path?query#hash').
* Rejects absolute URLs ('https://...', 'http://...'), protocol-relative URLs
* ('//example.com'), backslash-prefixed forms ('\\example.com' — some browsers
* normalize these), and non-string inputs.
*/
export function isSafeRedirect(target: unknown): target is string {
if (typeof target !== 'string' || target.length === 0) return false;
if (!target.startsWith('/')) return false;
if (target.startsWith('//') || target.startsWith('/\\')) return false;
return true;
}
/**
* Return the input when it passes isSafeRedirect, otherwise '/'. Use this on
* any post-login / post-callback redirect target derived from user input.
*/
export function safeRedirectOrRoot(target: unknown): string {
return isSafeRedirect(target) ? target : '/';
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Pure scanner override resolver (#1219).
*
* Decides which networkMode + DNS values to pass to the scanner container,
* given (auto-detected, user-settings) inputs. Kept SvelteKit/server-free so
* the unit tests can import it without triggering DB initialisation.
*/
export function resolveScannerOverrides(
autoDetected: { networkMode?: string; extraHosts?: string[] },
userSettings: { networkMode?: string; dns?: string[] }
): { networkMode?: string; dns?: string[]; extraHosts?: string[] } {
const userNet = userSettings.networkMode?.trim();
const networkMode = userNet ? userNet : autoDetected.networkMode || undefined;
const cleanedDns = (userSettings.dns ?? [])
.map((d) => d.trim())
.filter((d) => d.length > 0);
const dns = cleanedDns.length > 0 ? cleanedDns : undefined;
const extraHosts =
autoDetected.extraHosts && autoDetected.extraHosts.length > 0
? autoDetected.extraHosts
: undefined;
return { networkMode, dns, extraHosts };
}