Skip to content
Merged
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
47 changes: 47 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Build web/ Next.js static export and push to gh-pages branch.
# Triggers on pushes to main that touch web/ files, or on manual dispatch.

name: Deploy Pages

on:
push:
branches: [main]
paths:
- "web/**"
workflow_dispatch:

permissions:
contents: write

defaults:
run:
working-directory: web

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: latest

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm run build

- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: web/out
force_orphan: true
49 changes: 49 additions & 0 deletions .github/workflows/nextjs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Next.js CI — runs only when files under web/ change (add your app at ./web).
# To use the repo root instead, remove defaults.run.working-directory and adjust cache paths.

name: Next.js

on:
push:
branches: [main, develop]
paths:
- "web/**"
pull_request:
branches: [main, develop]
paths:
- "web/**"

permissions:
contents: read

defaults:
run:
working-directory: web

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: latest

- uses: actions/setup-node@v4
with:
node-version: "24"
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm run --if-present lint

- name: Build
run: pnpm run build

- name: Test
run: pnpm run --if-present test
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to this project will be documented in this file.

## [0.9.0] — 2026-03-22

### Added
- **Shared HTTP client** (`http-request`) for validation and hooks with timeouts and response size limits.
- **HTTP hook SSRF mitigation** — resolves hook URLs and blocks private/loopback targets by default; override with `Q_RING_ALLOW_PRIVATE_HOOKS=1` if needed. Denied attempts emit `policy_deny` audit events.
- **Next.js GitHub Pages site** (`web/`) — Tailwind CSS v4, Motion animations, Getting Started (`/docs`) and Changelog (`/changelog`) pages, mobile nav, copyable terminals, animated stats, interactive architecture diagram. Deploy via `deploy-pages.yml` and CI via `nextjs.yml`.

### Changed
- **Dashboard** — pathname routing fixes, SSE backpressure, tighter CORS, inline/system fonts and assets for offline use.
- **README** — notes on SSRF protection for HTTP hooks.

## [0.4.0] — 2026-03-22

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ qring hook test <id>

Hooks are fire-and-forget: a failing hook never blocks secret operations. The hook registry is stored at `~/.config/q-ring/hooks.json`.

**SSRF protection:** HTTP hook URLs targeting private/loopback IP ranges (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7`) are blocked by default. DNS resolution is checked before the request is sent. To allow hooks targeting local services (e.g. during development), set the environment variable `Q_RING_ALLOW_PRIVATE_HOOKS=1`.

### Configurable Rotation

Set a rotation format per secret so the agent auto-rotates with the correct value shape.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@i4ctime/q-ring",
"version": "0.4.0",
"version": "0.9.0",
"mcpName": "io.github.I4cTime/q-ring",
"description": "Quantum keyring for AI coding tools — Cursor, Kiro, Claude Code. Secrets, superposition, entanglement, MCP.",
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"url": "https://github.com/I4cTime/quantum_ring",
"source": "github"
},
"version": "0.4.0",
"version": "0.9.0",
"packages": [
{
"registryType": "npm",
"identifier": "@i4ctime/q-ring",
"version": "0.4.0",
"version": "0.9.0",
"transport": {
"type": "stdio"
}
Expand Down
9 changes: 3 additions & 6 deletions src/core/dashboard-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ export function getDashboardHtml(): string {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>q-ring — quantum status</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
Expand All @@ -41,8 +38,8 @@ export function getDashboardHtml(): string {
--violet:#a855f7;
--pink:#ff0055;

--font-display:'Outfit',sans-serif;
--font-mono:'JetBrains Mono',monospace;
--font-display:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
--font-mono:'SF Mono','Cascadia Code','Fira Code',Consolas,'Liberation Mono',monospace;

--radius:12px;
--radius-sm:8px;
Expand Down Expand Up @@ -180,7 +177,7 @@ body{min-height:100vh;overflow-x:hidden;position:relative}
<div class="container">
<div class="header">
<h1>
<span class="q-icon"><img src="https://i4ctime.github.io/quantum_ring/assets/icon.png" alt="q-ring" width="28" height="28" style="display:block;border-radius:4px"/></span>
<span class="q-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="url(#neon-grad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg></span>
<span class="brand">q-ring</span>
<span class="sub">quantum status</span>
</h1>
Expand Down
28 changes: 20 additions & 8 deletions src/core/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,26 +132,40 @@ export function startDashboardServer(
const snapshot = collectSnapshot();
const data = `data: ${JSON.stringify(snapshot)}\n\n`;
for (const res of clients) {
if (res.writableEnded || res.destroyed) {
clients.delete(res);
continue;
}
try {
res.write(data);
const ok = res.write(data);
if (!ok) {
clients.delete(res);
try { res.end(); } catch { try { res.destroy(); } catch { /* noop */ } }
}
} catch {
clients.delete(res);
try { res.end(); } catch { try { res.destroy(); } catch { /* noop */ } }
}
}
}

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const url = req.url ?? "/";
let pathname: string;
try {
({ pathname } = new URL(req.url ?? "/", "http://127.0.0.1"));
} catch {
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Bad Request: invalid URL");
return;
}

if (url === "/events") {
if (pathname === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
});

// Send initial snapshot immediately
const snapshot = collectSnapshot();
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);

Expand All @@ -160,17 +174,15 @@ export function startDashboardServer(
return;
}

if (url === "/api/status") {
if (pathname === "/api/status") {
const snapshot = collectSnapshot();
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
res.end(JSON.stringify(snapshot, null, 2));
return;
}

// Serve the dashboard HTML
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
});
Expand Down
119 changes: 81 additions & 38 deletions src/core/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { exec } from "node:child_process";
import { request as httpsRequest } from "node:https";
import { request as httpRequest } from "node:http";
import { randomUUID } from "node:crypto";
import { lookup } from "node:dns/promises";
import { httpRequest_ } from "../utils/http-request.js";
import { logAudit } from "./observer.js";

export type HookType = "shell" | "http" | "signal";
Expand Down Expand Up @@ -173,46 +173,89 @@ function executeShell(command: string, payload: HookPayload): Promise<HookResult
});
}

function executeHttp(url: string, payload: HookPayload): Promise<HookResult> {
return new Promise((resolve) => {
const body = JSON.stringify(payload);
const parsedUrl = new URL(url);
const reqFn = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
function isPrivateIP(ip: string): boolean {
// Normalize IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)
const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
const ipv4Mapped = ip.match(ipv4Re);
if (ipv4Mapped) return isPrivateIP(ipv4Mapped[1]);

// IPv4 private/loopback/link-local
if (/^127\./.test(ip)) return true;
if (/^10\./.test(ip)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
if (/^192\.168\./.test(ip)) return true;
if (/^169\.254\./.test(ip)) return true;
if (ip === "0.0.0.0") return true;
// IPv6 loopback and private
if (ip === "::1" || ip === "::") return true;
if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true;
if (/^fe80:/i.test(ip)) return true;
return false;
}

const req = reqFn(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"User-Agent": "q-ring-hooks/1.0",
},
timeout: 10000,
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
hookId: "",
success: (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300,
message: `HTTP ${res.statusCode}`,
});
});
},
);
async function checkSSRF(url: string): Promise<string | null> {
if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;

try {
const parsed = new URL(url);
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");

// If the hostname is already an IP literal, check it directly.
if (isPrivateIP(hostname)) {
return `Blocked: hook URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
}

// Resolve all addresses (A and AAAA) and block if any are private.
const results = await lookup(hostname, { all: true });
for (const { address } of results) {
if (isPrivateIP(address)) {
return `Blocked: hook URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
}
}
} catch {
// DNS failure will surface as a request error downstream
}
return null;
}

req.on("error", (err) => {
resolve({ hookId: "", success: false, message: `HTTP error: ${err.message}` });
async function executeHttp(url: string, payload: HookPayload): Promise<HookResult> {
const ssrfBlock = await checkSSRF(url);
if (ssrfBlock) {
logAudit({
action: "policy_deny",
key: payload.key,
scope: payload.scope,
source: payload.source,
detail: `hook SSRF blocked: ${url}`,
});
req.on("timeout", () => {
req.destroy();
resolve({ hookId: "", success: false, message: "HTTP timeout" });
return { hookId: "", success: false, message: ssrfBlock };
}

try {
const body = JSON.stringify(payload);
const res = await httpRequest_({
url,
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "q-ring-hooks/1.0",
},
body,
timeoutMs: 10_000,
});
req.write(body);
req.end();
});
return {
hookId: "",
success: res.statusCode >= 200 && res.statusCode < 300,
message: `HTTP ${res.statusCode}`,
};
} catch (err) {
return {
hookId: "",
success: false,
message: err instanceof Error ? err.message : "HTTP error",
};
}
}

function executeSignal(
Expand Down
Loading
Loading