Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ RELAY_URL=ws://localhost:3000
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist

# -----------------------------------------------------------------------------
# Admin Dashboard (private moderation surface)
# -----------------------------------------------------------------------------
# Host name that serves the read-only moderation dashboard and its
# /api/admin/v1 endpoints. Leave unset to keep the admin surface absent.
# Setting it requires one of the two authentication variables below.
# BUZZ_ADMIN_HOST=admin.localhost:3000
#
# Option A — Bearer token (recommended for public-facing / OSS deployments):
# Exactly 64 hex characters (32 bytes); generate with `openssl rand -hex 32`.
# `just admin` generates a throwaway one per run and prints it.
# BUZZ_ADMIN_TOKEN=<64 hex characters>
#
# Option B — Network-layer auth (for deployments behind a VPN or firewall):
# Set only when the admin API is already protected at the network layer.
# The relay logs a WARN on every startup in this mode.
# Only the exact value "true" is accepted; any other non-empty value fails.
# BUZZ_ADMIN_INSECURE_NO_AUTH=true
#
# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
# BUZZ_ADMIN_WEB_DIR=./admin-web/dist

# Shared Redis-backed admission limits. Defaults shown below; each value must
# be a positive integer.
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

- **Breaking:** the relay admin moderation API (`/api/admin/v1`) now requires
explicit authentication when `BUZZ_ADMIN_HOST` is set. Choose one mode:
- **Token mode:** set `BUZZ_ADMIN_TOKEN` to exactly 64 hex characters
(`openssl rand -hex 32`). Every API request requires `Authorization: Bearer`.
The dashboard prompts for the token on first load.
- **Network-layer mode:** set `BUZZ_ADMIN_INSECURE_NO_AUTH=true` (exact value
only). Use only when the admin API is already protected by a VPN or private
ingress. The relay logs a `WARN` on every startup. The dashboard skips the
token prompt and renders directly.
- Both set at the same time, or neither, is a startup error.
`Host`/`Origin` matching is retained in both modes as defense-in-depth.

## v0.5.3

### Desktop and shared changes
Expand Down
6 changes: 5 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,11 @@ admin: bootstrap _ensure-migrations
pnpm -C admin-web build
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
# The relay refuses to start without a token, and never logs one. Mint a
# throwaway per run so no dev secret is ever committed or reused.
export BUZZ_ADMIN_TOKEN="${BUZZ_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
echo "Admin token (paste into dashboard prompt): ${BUZZ_ADMIN_TOKEN}"
cargo run -p buzz-relay

# Seed deterministic reports and product feedback for local admin dashboard review
Expand All @@ -403,7 +407,7 @@ admin-check: fmt-check
cargo test -p buzz-relay api::admin
cargo test -p buzz-relay router::tests
pnpm -C admin-web check
pnpm -C admin-web exec playwright test
pnpm -C admin-web test:e2e

# Start the relay server in release mode
relay-release: _ensure-migrations
Expand Down
152 changes: 137 additions & 15 deletions admin-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
useMemo,
useState,
} from "react";
import { ApiFailure, request } from "./api";
import {
ApiFailure,
probeAuthRequired,
request,
requestObjectUrl,
} from "./api";
import { setToken, useToken } from "./token";
import type {
FeedbackDetail,
FeedbackSummary,
Expand Down Expand Up @@ -460,7 +466,7 @@ function FeedbackDetailView({ id }: { id: string }) {
<dd className="attachments">
{attachments.map((attachment) => (
<Attachment
key={`${attachment.hash}-${attachment.url}`}
key={`${attachment.hash}-${attachment.path}`}
attachment={attachment}
/>
))}
Expand Down Expand Up @@ -491,7 +497,7 @@ function FeedbackDetailView({ id }: { id: string }) {
type FeedbackStatuses = Record<string, boolean>;

interface FeedbackAttachment {
url: string;
path: string;
sourceUrl: string;
mimeType: string;
hash: string;
Expand Down Expand Up @@ -551,7 +557,7 @@ function feedbackAttachments(
const parsedSize = Number(values.get("size"));
return [
{
url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
sourceUrl: safeUrl,
mimeType,
hash,
Expand Down Expand Up @@ -596,7 +602,9 @@ function stripAttachmentMarkdown(
}

function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
const url = attachment.url;
const [objectUrl, setObjectUrl] = useState<string>();
const [failed, setFailed] = useState(false);
const path = attachment.path;
const name =
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
const metadata = [
Expand All @@ -607,33 +615,76 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
.filter(Boolean)
.join(" · ");

useEffect(() => {
// The API requires an Authorization header, so the bytes are fetched here
// and handed to the DOM as an object URL revoked on replacement/unmount.
let url: string | undefined;
let active = true;
setObjectUrl(undefined);
setFailed(false);
requestObjectUrl(path)
.then((created) => {
if (!active) {
URL.revokeObjectURL(created);
return;
}
url = created;
setObjectUrl(created);
})
.catch(() => {
if (active) setFailed(true);
});
return () => {
active = false;
if (url) URL.revokeObjectURL(url);
};
}, [path]);

const detail = failed ? "Could not load attachment" : metadata;

if (attachment.mimeType.startsWith("image/")) {
return (
<figure className="image-attachment">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={name} loading="lazy" />
</a>
{objectUrl ? (
<a href={objectUrl} target="_blank" rel="noreferrer">
<img src={objectUrl} alt={name} />
</a>
) : (
<div className="attachment-placeholder">
{failed ? "Unavailable" : "Loading…"}
</div>
)}
<figcaption>
<span>{name}</span>
<small>{metadata}</small>
<small>{detail}</small>
</figcaption>
</figure>
);
}

const label = (
<>
<FileIcon />
<span>
<strong>{name}</strong>
<small>{detail}</small>
</span>
</>
);

// Until the bytes are fetched there is nothing a link could point at: the
// API path itself would open unauthenticated in a new tab.
if (!objectUrl) return <div className="file-attachment">{label}</div>;

return (
<a
className="file-attachment"
href={url}
href={objectUrl}
target="_blank"
rel="noreferrer"
download={name}
>
<FileIcon />
<span>
<strong>{name}</strong>
<small>{metadata}</small>
</span>
{label}
<ArrowIcon />
</a>
);
Expand Down Expand Up @@ -797,10 +848,81 @@ function ArrowIcon() {
);
}

/// The whole dashboard is behind the credential: with no token in session
/// storage there is nothing worth rendering, and every API call would 401.
function TokenPrompt({ rejected }: { rejected: boolean }) {
const [value, setValue] = useState("");
return (
<div className="app">
<form
className="state token-prompt"
onSubmit={(event) => {
event.preventDefault();
const token = value.trim();
if (token) setToken(token);
}}
>
<h2>Admin token required</h2>
<p>
{rejected
? "That token was rejected. Enter the operator token for this deployment."
: "Enter the operator token for this deployment. It is kept for this browser session only."}
</p>
<label>
<span className="visually-hidden">Admin token</span>
<input
type="password"
name="token"
autoComplete="off"
placeholder="Admin token"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</label>
<button type="submit">Continue</button>
</form>
</div>
);
}

export function App() {
const { path } = usePath();
const token = useToken();
// Distinguishes the first visit from a token the relay just rejected.
const [everHadToken, setEverHadToken] = useState(token !== null);
useEffect(() => {
if (token !== null) setEverHadToken(true);
}, [token]);

// When no token is stored, probe the relay to find out whether it requires
// one. In insecure_no_auth mode the probe returns 200 and the dashboard
// renders without a credential; in token mode it returns 401 and the prompt
// is shown. `null` means the probe is still in flight.
const [authRequired, setAuthRequired] = useState<boolean | null>(
token !== null ? false : null,
);
useEffect(() => {
if (token !== null) {
setAuthRequired(false);
return;
}
let active = true;
probeAuthRequired().then((required) => {
if (active) setAuthRequired(required);
});
return () => {
active = false;
};
}, [token]);

const report = path.match(/^\/reports\/([^/]+)$/);
const feedback = path.match(/^\/feedback\/([^/]+)$/);

// Probe still in flight — render nothing to avoid a visible flash.
if (authRequired === null) return null;

if (authRequired && !token) return <TokenPrompt rejected={everHadToken} />;

const content = report ? (
<ReportDetail id={report[1]} />
) : feedback ? (
Expand Down
54 changes: 52 additions & 2 deletions admin-web/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { clearToken, getToken } from "./token";

const PREFIX = "/api/admin/v1";

export class ApiFailure extends Error {
Expand All @@ -9,17 +11,65 @@ export class ApiFailure extends Error {
}
}

export async function request<T>(path: string): Promise<T> {
/// Every admin API call goes through here so the operator token is attached
/// exactly once and a rejected credential is cleared in exactly one place.
/// When no token is present the request is sent without an Authorization
/// header; in insecure_no_auth mode the relay returns 200, while in token
/// mode it returns 401 and the caller discovers that auth is required.
async function send(path: string, accept: string): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = { accept };
if (token) headers.authorization = `Bearer ${token}`;
const response = await fetch(`${PREFIX}${path}`, {
credentials: "same-origin",
headers: { accept: "application/json" },
headers,
});
if (response.status === 401) {
// Idempotent: concurrent 401s collapse into a single re-prompt.
clearToken();
throw new ApiFailure(401, "The admin token was rejected.");
}
if (!response.ok) {
const envelope = await response.json().catch(() => null);
throw new ApiFailure(
response.status,
envelope?.error?.message ?? `Request failed (${response.status})`,
);
}
return response;
}

export async function request<T>(path: string): Promise<T> {
const response = await send(path, "application/json");
return response.json() as Promise<T>;
}

/// Probe whether the relay requires a bearer token. Issues one unauthenticated
/// request and returns `true` if auth is required (token prompt should be shown)
/// or `false` if the relay returned 200 (insecure_no_auth mode, no token needed).
/// Used by the App shell to skip the token prompt for deployments that rely on
/// network-layer access control.
export async function probeAuthRequired(): Promise<boolean> {
try {
const response = await fetch(`${PREFIX}/reports`, {
credentials: "same-origin",
headers: { accept: "application/json" },
});
// 200 → insecure_no_auth mode, no token needed.
// Anything else — including 401, 403, 404, 5xx — means auth is required
// or the deployment is misconfigured. Show the prompt rather than silently
// skipping it.
return response.status !== 200;
} catch {
// Network error — treat as auth-required so the prompt is shown.
return true;
}
}

/// Attachments cannot be fetched by `<img src>` or `<a href>` because those
/// carry no Authorization header. Callers render the object URL and must
/// revoke it when it is replaced or unmounted.
export async function requestObjectUrl(path: string): Promise<string> {
const response = await send(path, "*/*");
return URL.createObjectURL(await response.blob());
}
20 changes: 20 additions & 0 deletions admin-web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,26 @@ dd {
color: #9f2424;
}

.token-prompt input {
width: min(22rem, 70vw);
padding: 0.65rem 0.9rem;
border: 1px solid rgb(35 30 30 / 16%);
border-radius: 0.75rem;
font: inherit;
}

.token-prompt button {
margin-top: 1rem;
}

.attachment-placeholder {
min-height: 8rem;
display: grid;
place-content: center;
color: rgb(35 30 30 / 48%);
font-size: 0.8rem;
}

@media (max-width: 720px) {
.app-header {
width: min(100% - 2rem, 1120px);
Expand Down
Loading
Loading