Skip to content

P1-2: localhost APIに認可トークン/Origin-Host検証/magic byte/上限/レート制限を追加 - #15

Merged
rahiseko-alt merged 2 commits into
mainfrom
p1-2-localhost-api-hardening
Jul 28, 2026
Merged

rahiseko-alt merged 2 commits into
mainfrom
p1-2-localhost-api-hardening

Conversation

@rahiseko-alt

@rahiseko-alt rahiseko-alt commented Jul 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • video-shorts/server/security.mjs を新設し、localhost APIのハードニングを一元化
    • (A) 起動時トークン: generateStartupToken()。index.html配信時にのみwindow.__KOSESPARK_TOKEN__として埋め込む(別オリジンはレスポンス本文を読めないため盗めない)
    • (B) Origin/Host検証: isAllowedHost/isAllowedOrigin(DNS rebinding・他オリジンからのfetch/XHRを拒否)
    • (C) magic byte検証: looksLikeVideo(MP4/WebM/AVI/MPEG-TSのシグネチャ)。該当しなければ415
    • (D) アップロード上限: MAX_UPLOAD_BYTES(500MB)。宣言Content-Lengthの事前チェックと受信バイト数の実測(宣言が無い/嘘の場合の保険)の両方で強制し413
    • (E) レート制限: createRateLimiter(固定ウィンドウ・60秒10回)。超過は429
  • server/index.mjsの全/api/*入口でトークン+Origin/Host検証を必須化。POST /api/jobsにレート制限・アップロード上限・magic byte検証を適用
  • webapp-mockup/app.jsをトークン付き呼び出しへ更新(fetchはヘッダ、EventSource/ダウンロードリンクはカスタムヘッダを付けられないためクエリ)
  • docs/roadmap.htmlのP1-2配下(原子ツリー)を更新

Test plan

  • pnpm -r --if-present typecheck(該当パッケージ無し)
  • pnpm -r --if-present lint(該当パッケージ無し)
  • pnpm -r test(video-shorts: tests/smoke.mjs 39 PASS + transcribe-corrections-check.py 5 PASS)
    • 追加した契約テスト8件: トークン生成/検証・Host/Origin検証・magic byte・アップロード上限・レート制限(ウィンドウ内拒否/ウィンドウ経過後の再許可)
  • pnpm -r --if-present build
  • pnpm audit --audit-level moderate(既知の脆弱性なし)
  • node scripts/verify-roadmap-evidence.mjs(roadmap evidence リンタ OK)
  • 実サーバーを起動しcurlで実地確認: 無トークン→401、正しいトークン+不正Origin→401、正しいトークン+正しいOrigin→404(job無し、認可は通過)、非動画アップロード→415、動画らしきアップロード→202

CIが緑になり次第、docs/roadmap.htmlのP1-2-A〜EのevidenceにCI run URLを記入する追いコミットを行います。


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Added startup token authorization and localhost Host/Origin validation for API requests.
    • Enforced upload safeguards, including early size checks, magic-byte video detection, and fixed-window rate limiting.
    • Updated the web app to include tokens for job creation, live job updates, and clip downloads.
  • Documentation
    • Updated the roadmap to reflect completion of the P1-2 security/hardening milestone.
  • Tests
    • Added/expanded smoke tests covering token handling, request validation, upload checks, and rate limiter behavior.

同じPC上の別アプリや悪意あるWebページから、勝手にジョブを起動されたり
成果物を盗まれたりしないよう、server/security.mjs を新設して5点を実装。

- (A) 起動時トークン: generateStartupToken()。index.html配信時にのみ
  window.__KOSESPARK_TOKEN__として埋め込む(別オリジンはレスポンス本文を
  読めないため盗めない)
- (B) Origin/Host検証: isAllowedHost/isAllowedOrigin(DNS rebinding・
  他オリジンからのfetch/XHRを拒否)
- (C) magic byte検証: looksLikeVideo(MP4/WebM/AVI/MPEG-TSのシグネチャ)。
  該当しなければ415
- (D) アップロード上限: MAX_UPLOAD_BYTES(500MB)。宣言Content-Lengthの
  事前チェックと受信バイト数の実測(宣言が無い/嘘の場合の保険)の両方で
  強制し413
- (E) レート制限: createRateLimiter(固定ウィンドウ・60秒10回)。超過は429

server/index.mjsの全/api/*入口でトークン+Origin/Host検証を必須化。
webapp-mockup/app.jsをトークン付き呼び出し(fetchはヘッダ、EventSource/
ダウンロードリンクはカスタムヘッダを付けられないためクエリ)へ更新。
tests/smoke.mjsにP1-2-A〜E契約テスト8件を追加(計39 PASS)。ローカルで
実サーバーを起動し、curlで無トークン401・不正Origin401・非動画415・
正常202を実地確認済み。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01For7g76mPTJAQSBTfYBrwU
@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f407e167-fe5e-430d-87cc-b47141944ed4

📥 Commits

Reviewing files that changed from the base of the PR and between 0320140 and fade326.

📒 Files selected for processing (1)
  • docs/roadmap.html

📝 Walkthrough

Walkthrough

The PR adds localhost-focused API hardening with per-startup token authorization, Host/Origin validation, upload signature and size checks, rate limiting, client token propagation, smoke tests, and roadmap updates marking the security controls complete.

Changes

Security Hardening

Layer / File(s) Summary
Security primitives and validation tests
video-shorts/server/security.mjs, video-shorts/tests/smoke.mjs
Adds startup-token utilities, localhost Host/Origin allowlisting, video magic-byte detection, a 500 MB upload cap, fixed-window rate limiting, and focused smoke tests.
Server authorization and upload enforcement
video-shorts/server/index.mjs
Injects the startup token into index.html, gates /api/*, rate-limits job creation, validates upload size and video signatures, and cleans up rejected uploads.
Client authentication propagation
video-shorts/webapp-mockup/app.js
Adds the startup token to fetch headers and to query URLs used by EventSource and clip downloads.
Security milestone tracking
docs/roadmap.html
Advances the roadmap to P1-3 and marks P1-2 controls complete with acceptance evidence.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed localhost APIへの認可トークン、Origin/Host検証、magic byte、上限、レート制限の追加を的確に要約しています。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch p1-2-localhost-api-hardening

Comment @coderabbitai help to get the list of available commands.

CI(ci-green/roadmap-required/CodeQL)全て緑を確認し、P1-2-A〜Eの
evidenceに該当CI run URLを記入してdone化。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01For7g76mPTJAQSBTfYBrwU
@rahiseko-alt
rahiseko-alt merged commit 88ef3a5 into main Jul 28, 2026
10 of 11 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
video-shorts/server/index.mjs (2)

140-151: 🔒 Security & Privacy | 🔵 Trivial

Consider a Referrer-Policy header given token-in-URL usage.

The startup token also travels via ?token= for EventSource/clip-download URLs (per app.js's withTokenQuery), since custom headers aren't possible there. That's a reasonable workaround for the API limitation, but it does mean the token could end up in a Referer header if the page later loads any external resource. Setting Referrer-Policy: no-referrer (or same-origin) on responses would close that residual leakage path at low cost.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@video-shorts/server/index.mjs` around lines 140 - 151, Update the response
headers in the index.html handling within the request-serving flow to include a
restrictive Referrer-Policy, preferably no-referrer, alongside Content-Type and
Content-Length. Ensure the policy is applied to the token-bearing page response
without changing the token injection behavior.

207-213: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Magic-byte check only inspects the first data event's chunk.

checkedSignature is only evaluated once, on whatever bytes happen to arrive in the first req.on("data") callback. If that first chunk is smaller than the minimum bytes a signature needs (e.g., under 8 bytes for the MP4 ftyp check), a legitimate video could be falsely rejected with 415 purely due to TCP segmentation, independent of file validity.

Consider buffering a minimum number of bytes (e.g., 16) across chunks before running looksLikeVideo.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@video-shorts/server/index.mjs` around lines 207 - 213, Update the request
data handling around checkedSignature and looksLikeVideo to accumulate bytes
across data chunks until at least the minimum signature length (for example, 16
bytes) is available, then perform the magic-byte check once. Preserve byte
counting, rejection handling, and the existing 415 abort for invalid signatures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@video-shorts/server/index.mjs`:
- Around line 200-232: The abort handler in the upload streaming flow must
prevent rejected request bytes from remaining on a keep-alive connection. Update
abort to drain the request body or explicitly close the HTTP connection by
setting Connection: close and terminating the request/socket, while preserving
the existing 413/415 response and cleanup behavior.

In `@video-shorts/server/security.mjs`:
- Around line 46-69: The MPEG-TS predicate in VIDEO_SIGNATURES currently accepts
any buffer beginning with 0x47; require repeated 0x47 sync bytes at 188-byte
intervals, checking at least two or three points while preserving the existing
safe false result for short or invalid buffers. In
video-shorts/server/security.mjs lines 46-69 update this predicate; in
video-shorts/tests/smoke.mjs lines 404-413 add positive AVI and strengthened
MPEG-TS cases plus a negative case for non-TS data beginning with 0x47.

---

Nitpick comments:
In `@video-shorts/server/index.mjs`:
- Around line 140-151: Update the response headers in the index.html handling
within the request-serving flow to include a restrictive Referrer-Policy,
preferably no-referrer, alongside Content-Type and Content-Length. Ensure the
policy is applied to the token-bearing page response without changing the token
injection behavior.
- Around line 207-213: Update the request data handling around checkedSignature
and looksLikeVideo to accumulate bytes across data chunks until at least the
minimum signature length (for example, 16 bytes) is available, then perform the
magic-byte check once. Preserve byte counting, rejection handling, and the
existing 415 abort for invalid signatures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bdab59e7-bd60-490c-ae55-48c5b870ab9b

📥 Commits

Reviewing files that changed from the base of the PR and between 599f8ff and 0320140.

📒 Files selected for processing (5)
  • docs/roadmap.html
  • video-shorts/server/index.mjs
  • video-shorts/server/security.mjs
  • video-shorts/tests/smoke.mjs
  • video-shorts/webapp-mockup/app.js

Comment on lines +200 to +232
const abort = (status, message) => {
rejection = { status, message };
req.pause();
ws.destroy();
reject(new Error(message));
};

req.on("data", (chunk) => {
if (rejection) return;
receivedBytes += chunk.length;
if (!checkedSignature) {
checkedSignature = true;
if (!looksLikeVideo(chunk)) return abort(415, "動画ファイルとして認識できません");
}
if (receivedBytes > MAX_UPLOAD_BYTES) return abort(413, "ファイルが大きすぎます");
ws.write(chunk);
});
req.on("end", () => {
if (!rejection) ws.end();
});
req.on("error", (e) => {
if (!rejection) reject(e);
});
ws.on("finish", resolve);
ws.on("error", (e) => {
if (!rejection) reject(e);
});
});
} catch (e) {
fs.rmSync(workDir, { recursive: true, force: true });
if (rejection) return jsonRes(res, rejection.status, { error: rejection.message });
throw e;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Node.js http server: does destroying the request (req.destroy()) also close the response socket before res.end() flushes?

💡 Result:

Yes, calling req.destroy on a Node.js HTTP server request effectively destroys the underlying socket associated with the HTTP transaction [1]. Because the request and response share the same underlying socket, destroying the request closes that socket, which immediately terminates any pending response activity [2][3][1]. When req.destroy is invoked: 1. The socket is destroyed [4][5]. 2. Any pending data in the response stream is dropped [4][5]. 3. The response does not continue to flush or complete; the communication channel is severed [2][1]. As a result, if you call req.destroy before res.end has completed its work, the remaining buffered response data will not be sent, and the response process will be aborted [4][2]. In Node.js, this is treated as a connection-level action, and it is generally recommended to assume that the response is also destroyed once req.destroy is called [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and inspect the relevant region with line numbers.
ast-grep outline video-shorts/server/index.mjs --view expanded || true
echo "----"
sed -n '150,280p' video-shorts/server/index.mjs | cat -n

Repository: rahiseko-alt/ai-editer

Length of output: 5660


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect response helper and server setup to see whether error responses keep the connection alive.
sed -n '90,140p' video-shorts/server/index.mjs | cat -n
echo "----"
sed -n '328,430p' video-shorts/server/index.mjs | cat -n

Repository: rahiseko-alt/ai-editer

Length of output: 5286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '90,140p' video-shorts/server/index.mjs | cat -n
echo "----"
sed -n '328,430p' video-shorts/server/index.mjs | cat -n

Repository: rahiseko-alt/ai-editer

Length of output: 5286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any explicit connection-close or request-destroy handling elsewhere in the server.
rg -n "destroy\(|Connection|keepAlive|setHeader\(\"Connection\"|req\.pause|req\.resume|unpipe" video-shorts/server/index.mjs

Repository: rahiseko-alt/ai-editer

Length of output: 245


🌐 Web query:

Node.js HTTP server unread request body keep-alive pause unread bytes socket reuse

💡 Result:

In a Node.js HTTP server using keep-alive connections, failing to read or "drain" a request body can disrupt socket reuse and lead to connection hangs or corruption [1][2][3]. When an HTTP request includes a body and the server handler sends a response without fully reading that body, the unconsumed bytes remain in the TCP stream [1]. Because the server must maintain a clean state for the next pipelined request on the same keep-alive connection, these leftover bytes cause the Node.js HTTP parser to encounter data unexpectedly [1][3]. This can lead to the parser failing to identify the next request correctly, often resulting in the server resetting the connection (ECONNRESET) or effectively losing any subsequent requests sent on that same connection [1][4][2]. Key technical aspects include: 1. Backpressure and Pause: Node.js buffers incoming body data in the IncomingMessage object. If the application does not consume this stream, the buffer reaches its highWaterMark, and Node.js will pause the underlying socket to prevent memory exhaustion [1][3][5]. This pause stops further data from arriving, but it does not clear the data already buffered or the bytes still sitting in the socket's receive buffer [1][3]. 2. Socket Reuse Requirements: To reuse a socket for keep-alive, the server must reach a "clean" message end—meaning the entire request (headers and body) must be processed and accounted for [2][3]. If an application rejects a request (e.g., sends a 401 or 413 response) without consuming the body, it is a best practice to explicitly drain the remaining body bytes by calling req.resume or by attaching a data listener to consume and discard the stream [1][6][3]. 3. Connection Destruction: Many frameworks and developers choose to explicitly destroy the socket when a request is rejected and the body is unread [4][2]. This avoids the risk of data corruption on the next request, though it negates the performance benefits of keep-alive for that specific connection [2][3]. In summary, leaving a request body unread creates a discrepancy between the application's state and the TCP stream's state [3]. To ensure reliable keep-alive behavior, application code must either fully consume the request body or explicitly close the connection [1][3].

Citations:


Reject the upload by closing the connection. req.pause() leaves the rejected body unread on a keep-alive socket, so the 413/415 response can be followed by leftover bytes that break the next request on that connection. Drain the body or mark the response Connection: close and terminate the socket for this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@video-shorts/server/index.mjs` around lines 200 - 232, The abort handler in
the upload streaming flow must prevent rejected request bytes from remaining on
a keep-alive connection. Update abort to drain the request body or explicitly
close the HTTP connection by setting Connection: close and terminating the
request/socket, while preserving the existing 413/415 response and cleanup
behavior.

Comment on lines +46 to +69
/** 既知の動画コンテナのmagic byteだけを許可する(C)。誤検知よりも「本物の動画ファイルのはず」を優先。 */
const VIDEO_SIGNATURES = [
// MP4/MOV系: バイト4-7が "ftyp"
(buf) => buf.length >= 8 && buf.subarray(4, 8).toString("ascii") === "ftyp",
// WebM/Matroska (EBML header)
(buf) => buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3,
// AVI (RIFF....AVI )
(buf) =>
buf.length >= 12 &&
buf.subarray(0, 4).toString("ascii") === "RIFF" &&
buf.subarray(8, 12).toString("ascii") === "AVI ",
// MPEG-TS (先頭が同期バイト0x47。188バイト境界で複数回現れるのが正だが、先頭のみ簡易確認)
(buf) => buf.length >= 1 && buf[0] === 0x47,
];

export function looksLikeVideo(buf) {
return VIDEO_SIGNATURES.some((check) => {
try {
return check(buf);
} catch (_) {
return 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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

MPEG-TS magic-byte check is a near-noop (single byte) and untested. looksLikeVideo's MPEG-TS signature only checks that the first byte equals 0x47, which arbitrary non-video content can trivially satisfy, and the smoke tests never exercise this branch (or the AVI branch) to catch that weakness.

  • video-shorts/server/security.mjs#L46-L69: strengthen the MPEG-TS check to verify the 0x47 sync byte repeats at the 188-byte boundary (check at least 2-3 sync points) instead of a single leading byte.
  • video-shorts/tests/smoke.mjs#L404-L413: add positive cases for AVI and (the strengthened) MPEG-TS signatures, plus a negative case showing that a non-TS file starting with 0x47 is now correctly rejected.
📍 Affects 2 files
  • video-shorts/server/security.mjs#L46-L69 (this comment)
  • video-shorts/tests/smoke.mjs#L404-L413
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@video-shorts/server/security.mjs` around lines 46 - 69, The MPEG-TS predicate
in VIDEO_SIGNATURES currently accepts any buffer beginning with 0x47; require
repeated 0x47 sync bytes at 188-byte intervals, checking at least two or three
points while preserving the existing safe false result for short or invalid
buffers. In video-shorts/server/security.mjs lines 46-69 update this predicate;
in video-shorts/tests/smoke.mjs lines 404-413 add positive AVI and strengthened
MPEG-TS cases plus a negative case for non-TS data beginning with 0x47.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants