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
51 changes: 51 additions & 0 deletions __tests__/app/about.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockGetAppMeta = vi.fn();

vi.mock("@/lib/api", () => ({
getAppMeta: (...args: unknown[]) => mockGetAppMeta(...args),
}));

import AboutPage from "@/app/about/page";
import { BRANDING } from "@/lib/branding";

const fixedMeta = {
app_name: "OpenGit",
version: "1.2.3",
git_commit: "abc1234",
build_date: "2025-01-01T00:00:00Z",
license: "Apache-2.0",
source_url: "https://example.org/repo",
};

describe("AboutPage", () => {
beforeEach(() => {
mockGetAppMeta.mockReset();
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080");
});

it("shows loading state before promise resolves", () => {
mockGetAppMeta.mockReturnValue(new Promise(() => {}));

render(<AboutPage />);

expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(screen.getByText("dev")).toBeInTheDocument();
});

it("renders app name, version, and link to licenses", async () => {
mockGetAppMeta.mockResolvedValue(fixedMeta);

render(<AboutPage />);

await waitFor(() => {
expect(screen.getByRole("heading", { name: BRANDING.appName })).toBeInTheDocument();
expect(screen.getByText("1.2.3")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Apache-2.0" })).toHaveAttribute(
"href",
"/licenses",
);
});
});
});
59 changes: 59 additions & 0 deletions __tests__/app/licenses.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockGetAppLicenses = vi.fn();

vi.mock("@/lib/api", () => ({
getAppLicenses: (...args: unknown[]) => mockGetAppLicenses(...args),
}));

import LicensesPage from "@/app/licenses/page";

describe("LicensesPage", () => {
beforeEach(() => {
mockGetAppLicenses.mockReset();
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080");
});

it("renders third-party license entries", async () => {
mockGetAppLicenses.mockResolvedValue({
app_license: "Apache-2.0",
third_party: [
{
name: "react",
version: "18.0.0",
license: "MIT",
url: "https://react.dev",
},
{
name: "next",
version: "14.0.0",
license: "MIT",
url: "https://nextjs.org",
},
],
});

render(<LicensesPage />);

await waitFor(() => {
expect(screen.getByText("react")).toBeInTheDocument();
expect(screen.getByText("next")).toBeInTheDocument();
});
});

it("shows fallback text when third_party is empty", async () => {
mockGetAppLicenses.mockResolvedValue({
app_license: "Apache-2.0",
third_party: [],
});

render(<LicensesPage />);

await waitFor(() => {
expect(
screen.getByText("ライセンス情報を取得できませんでした"),
).toBeInTheDocument();
});
});
});
49 changes: 20 additions & 29 deletions __tests__/pages/about.test.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,34 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockGetAppMeta = vi.fn();

vi.mock("@/lib/api", () => ({
getAppMeta: (...args: unknown[]) => mockGetAppMeta(...args),
}));

import AboutPage from "@/app/about/page";

describe("AboutPage", () => {
beforeEach(() => {
mockGetAppMeta.mockReset();
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080");

vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/version")) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
version: "1.0.0",
commit: "abc1234",
buildDate: "2025-01-01T00:00:00Z",
},
}),
};
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
mockGetAppMeta.mockResolvedValue({
app_name: "OpenGit",
version: "1.0.0",
git_commit: "abc1234",
build_date: "2025-01-01T00:00:00Z",
license: "Apache-2.0",
source_url: "https://example.org/repo",
});
});

it("renders project name and includes a link", async () => {
const page = await AboutPage();
render(page);
render(<AboutPage />);

expect(screen.getByText("OpenGit")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("OpenGit")).toBeInTheDocument();
});
expect(document.querySelector("a")).not.toBeNull();
});
});
97 changes: 54 additions & 43 deletions app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,82 @@
"use client";

import Link from "next/link";
import { useEffect, useState } from "react";

import { getAppMeta } from "@/lib/api";
import type { AppMeta } from "@/lib/api-types";
import { BRANDING } from "@/lib/branding";

const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"";

interface VersionData {
version: string;
commit: string;
buildDate: string;
}
export default function AboutPage() {
const [meta, setMeta] = useState<AppMeta | null>(null);
const [loading, setLoading] = useState(true);

async function fetchVersion(): Promise<VersionData | null> {
try {
const response = await fetch(`${API_BASE}/api/v1/version`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
useEffect(() => {
let cancelled = false;

if (!response.ok) {
return null;
}
getAppMeta(process.env.NEXT_PUBLIC_API_BASE_URL ?? "")
.then((data) => {
if (!cancelled) {
setMeta(data);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});

const body = (await response.json()) as { data?: VersionData };
return body.data ?? null;
} catch {
return null;
}
}
return () => {
cancelled = true;
};
}, []);

export default async function AboutPage() {
const version = await fetchVersion();
const version = loading ? "dev" : (meta?.version ?? "dev");
const commit = meta?.git_commit ?? "unknown";
const buildDate = meta?.build_date ?? "unknown";
const licenseName = meta?.license ?? BRANDING.licenseName;
const sourceUrl = meta?.source_url ?? BRANDING.sourceUrl;

return (
<div className="mx-auto max-w-2xl px-6 py-12">
<h1 className="mb-8 text-3xl font-bold">{BRANDING.appName}</h1>
<h1 className="mb-6 text-3xl font-bold">{BRANDING.appName}</h1>

{loading ? <p className="mb-4 text-muted-foreground">Loading...</p> : null}

<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-gray-500">Version</dt>
<dd className="mt-1 text-base">{version?.version ?? "unknown"}</dd>
<dt className="text-sm font-medium text-muted-foreground">Version</dt>
<dd>{version}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Commit</dt>
<dd className="mt-1 font-mono text-sm">{version?.commit ?? "unknown"}</dd>
<dt className="text-sm font-medium text-muted-foreground">Commit</dt>
<dd className="font-mono text-sm">{commit}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Build Date</dt>
<dd className="mt-1 text-base">{version?.buildDate ?? "unknown"}</dd>
<dt className="text-sm font-medium text-muted-foreground">
Build Date
</dt>
<dd>{buildDate}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">License</dt>
<dd className="mt-1 text-base">{BRANDING.licenseName}</dd>
<dt className="text-sm font-medium text-muted-foreground">License</dt>
<dd>
<Link href="/licenses" className="text-primary underline">
{licenseName}
</Link>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source</dt>
<dd className="mt-1">
<Link
href={BRANDING.sourceUrl}
className="text-blue-600 hover:underline"
<dt className="text-sm font-medium text-muted-foreground">Source</dt>
<dd>
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
{BRANDING.sourceUrl}
</Link>
{sourceUrl}
</a>
</dd>
</div>
</dl>
Expand Down
73 changes: 73 additions & 0 deletions app/licenses/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import { useEffect, useState } from "react";

import { getAppLicenses } from "@/lib/api";
import type { AppLicenses } from "@/lib/api-types";

export default function LicensesPage() {
const [licenses, setLicenses] = useState<AppLicenses | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
getAppLicenses(process.env.NEXT_PUBLIC_API_BASE_URL ?? "")
.then(setLicenses)
.finally(() => setLoading(false));
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [high] licenses/page.tsx がアンマウント後に state を更新する可能性がある

該当箇所:

useEffect(() => {
  getAppLicenses(process.env.NEXT_PUBLIC_API_BASE_URL ?? "")
    .then(setLicenses)
    .finally(() => setLoading(false));
}, []);

about/page.tsx では cancelled フラグで cleanup しているのに、licenses/page.tsx には cleanup 関数が存在しない。ページから素早く離脱した場合、Promise が解決されたタイミングでアンマウント済みのコンポーネントに setLicenses / setLoading が呼ばれ、React の警告 (Can't perform a React state update on an unmounted component) が発生する(React 18 strict mode では開発時に表示される)。about/page.tsx と同様に cancelled フラグを導入すべき。

@@ -12,7 +12,14 @@
   useEffect(() => {
-    getAppLicenses(process.env.NEXT_PUBLIC_API_BASE_URL ?? "")
-      .then(setLicenses)
-      .finally(() => setLoading(false));
-  }, []);
+    let cancelled = false;
+    getAppLicenses(process.env.NEXT_PUBLIC_API_BASE_URL ?? "")
+      .then((data) => { if (!cancelled) setLicenses(data); })
+      .finally(() => { if (!cancelled) setLoading(false); });
+    return () => { cancelled = true; };
+  }, []);

code.null_safety.unmounted_state_update | confidence: 0.88


if (loading) {
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<p className="text-muted-foreground">Loading...</p>
</div>
);
}

return (
<div className="mx-auto max-w-4xl px-6 py-12">
<h1 className="mb-6 text-3xl font-bold">Licenses</h1>

<section className="mb-8">
<h2 className="mb-2 text-xl font-semibold">Project License</h2>
<p>{licenses?.app_license ?? ""}</p>
</section>

{licenses?.third_party.length === 0 ? (
<p>ライセンス情報を取得できませんでした</p>
) : (
<section>
<h2 className="mb-4 text-xl font-semibold">Third-Party Licenses</h2>
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b">
<th className="py-2 pr-4">Name</th>
<th className="py-2 pr-4">Version</th>
<th className="py-2 pr-4">License</th>
<th className="py-2">URL</th>
</tr>
</thead>
<tbody>
{licenses?.third_party.map((entry) => (
<tr key={`${entry.name}-${entry.version}`} className="border-b">
<td className="py-2 pr-4">{entry.name}</td>
<td className="py-2 pr-4">{entry.version}</td>
<td className="py-2 pr-4">{entry.license}</td>
<td className="py-2">
<a
href={entry.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
{entry.url}
</a>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [high] Open Redirect / 任意 URL インジェクション: APIレスポンスの entry.url を href に直接使用

CWE-601: URL Redirection to Untrusted Site ('Open Redirect') / CWE-20: Improper Input Validation

該当箇所:

<a
  href={entry.url}
  target="_blank"
  rel="noopener noreferrer"
  className="text-primary underline"
>
  {entry.url}
</a>

entry.url はバックエンドの /api/licenses から返されるサードパーティライセンス情報のフィールドです。このレスポンスが攻撃者によって改ざんされた場合(例: 中間者攻撃、バックエンド侵害)、javascript:alert(1)data:text/html,... など任意のスキームを含む URL がそのまま href にセットされ、XSS やフィッシングに利用されます。rel="noopener noreferrer" はタブナビゲーションの制御には有効ですが、javascript: URI の実行は防げません。

推奨対応:

function isSafeUrl(url: string): boolean {
  try {
    const { protocol } = new URL(url);
    return protocol === "https:" || protocol === "http:";
  } catch {
    return false;
  }
}

レンダリング前に isSafeUrl(entry.url) を検証し、不正な URL はリンクとしてレンダリングしない。

```tsx
{isSafeUrl(entry.url) ? (
  <a href={entry.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">
    {entry.url}
  </a>
) : (
  <span>{entry.url}</span>
)}

<sub>`sec.sast.injection.open_redirect` | confidence: 0.85</sub>

</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</div>
);
}
Loading
Loading