跳至主要內容

標籤「Web」的 5 篇文章

查看所有標籤

什麼是 CORS?從安全性角度為初學者解說

· 6 分鐘閱讀

説明 Web 瀏覽器的安全功能 CORS (Cross-Origin Resource Sharing),涵蓋「為什麼需要」和「有什麼風險」,為初學者量身打造的解説。正確理解 CORS 能夠實現安全的 Web 開發。

CORS 出現的背景:同源政策 (Same-Origin Policy)

在 1990 年代初期,JavaScript 被引入瀏覽器時,Web 安全的概念幾乎不存在。當時,惡意網站可以自由存取其他網站的資料,容易導致工作階段劫持 (Session Hijacking) 和資料竊取。

為了解決這個問題,引入了一項稱為同源政策 (Same-Origin Policy) 的限制。這是一個簡單而強大的規則:「從網頁加載的 JavaScript 無法存取該網頁不同源的資料」。

例如,從 https://www.example.com 加載的 JavaScript 無法存取 https://www.bank.com 的資料。這樣,即使使用者登入銀行網站後訪問惡意網站,銀行資訊也不會被盜取。

什麼是源 (Origin)

源 (Origin) 由以下三個要素決定:

  • 協議 (Protocol)http://https://
  • 主機 (Host/網域)example.comapi.example.com
  • 連接埠 (Port)808080

例如,

URL協議主機連接埠
https://www.example.com/pageHTTPSwww.example.com443 (預設)https://www.example.com
https://api.example.com/dataHTTPSapi.example.com443 (預設)https://api.example.com

由於主機不同,它們被視為不同的源

同源政策防止的事項

JavaScript 的 XHR (XMLHttpRequest)Fetch API 對不同源的請求受到限制。

例:evil.com 上的惡意指令碼

fetch('https://bank.example.com/api/transfer', {
method: 'POST',
body: JSON.stringify({ amount: 1000000 })
});

沒有同源政策的話,惡意網站 (evil.com) 的 JavaScript 可以在使用者登入銀行網站的狀態下發送轉帳請求。防止這種情況就是同源政策的目的

為什麼需要 CORS

然而,現代 Web 開發經常涉及多個源的協作。

  • 前端https://www.example.com
  • API 伺服器https://api.example.com
  • CDN / 靜態文件https://cdn.example.com

這些是由同一公司營運的正當通訊。但如果受到同源政策限制,應用程式就無法運作。

這時候 CORS (Cross-Origin Resource Sharing) 就派上用場了。

什麼是 CORS:明確允許存取

CORS 是伺服器明確聲明「允許來自此源的請求」的機制

伺服器只需返回以下回應標頭,瀏覽器就會放寬限制。

Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization

除非伺服器表示「允許」,否則瀏覽器不會將請求結果傳回 JavaScript。這樣既能實現跨源存取,又能保持安全性。

CORS 的安全風險:常見的設定錯誤

雖然 CORS 很便利,但設定不當會造成安全漏洞。

錯誤:允許所有源

Access-Control-Allow-Origin: *

這表示「世界上任何人都可以存取此伺服器」。

// https://evil.com 的 JavaScript
fetch('https://api.example.com/user/profile')
.then(r => r.json())
.then(data => {
// 竊取使用者個人資料的處理
console.log(data);
});

對於包含認證憑證(如 Cookie)的請求特別危險,使用者在登入 api.example.com 後訪問 evil.com 時,個人資訊可能被盜取。

fetch('https://api.example.com/user/profile', {
credentials: 'include' // 包含 Cookie
})

包含認證資訊時,不能使用 Access-Control-Allow-Origin: *。必須明確指定特定的源

Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true

錯誤:直接允許使用者指定的 URL

// 危險的實現範例(伺服器端)
const origin = request.headers.get('Origin');
response.headers.set('Access-Control-Allow-Origin', origin); // 原樣返回!

這樣做會允許來自 https://evil.com 的請求,回應會帶有 Access-Control-Allow-Origin: https://evil.com,容易被濫用。

正確做法是準備白名單,只允許列表中的源

const allowedOrigins = [
'https://www.example.com',
'https://admin.example.com'
];

if (allowedOrigins.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin);
}

錯誤:允許所有標頭

Access-Control-Allow-Headers: *

這表示「接受任何標頭」,允許透過自訂標頭注入惡意資料的攻擊。

只列出必要的標頭

Access-Control-Allow-Headers: Content-Type, Authorization

CORS 預檢請求:瀏覽器的事前確認

對於簡單請求以外的請求(GET、HEAD、POST),瀏覽器會自動發送 OPTIONS 方法的請求來確認「這樣可以嗎?」這被稱為預檢請求 (Preflight Request)。

1. JavaScript 嘗試發送 PUT 請求

2. 瀏覽器自動發送 OPTIONS 預檢請求
OPTIONS /api/resource HTTP/1.1
Origin: https://www.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

3. 伺服器回應「OK」
HTTP 200 OK
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Content-Type

4. 瀏覽器發送實際的 PUT 請求

如果伺服器不支援 OPTIONS 方法,預檢就會失敗,實際請求也不會被發送。

要點:

  • 在白名單中明確指定允許的源
  • 使用 credentials: true 處理包含 Cookie 認證的請求
  • 只允許必要的方法和標頭
  • OPTIONS 預檢請求

常見疑問

Q. 遇到 CORS 錯誤。可以允許所有源來解決嗎?

A: 不可以。可能會暫時有效,但在生產環境中允許 * 是安全風險。需要重新檢查伺服器端設定或重新設計 API。

Q. 想在本地開發時存取不同連接埠的源。可以嗎?

A: 在本地開發環境中只停用 CORS 是可以的。

Q. 從行動應用程式呼叫 API 時,CORS 有影響嗎?

A: CORS 是瀏覽器的安全功能,所以對行動應用程式沒有影響。取而代之,需要使用 API 金鑰或 OAuth 實作認證和授權。

總結

重點說明
CORS 的目的在保持瀏覽器安全性的同時允許跨源存取
危險的設定對需要認證的 API 使用 Access-Control-Allow-Origin: *
正確的設定在白名單中明確指定允許的源
包含 Cookie 時必須指定 Access-Control-Allow-Credentials: true 和具體的源
預檢PUT/DELETE 等複雜請求需要瀏覽器用 OPTIONS 進行事前確認

CORS 不只是「導致錯誤的原因」,而是 Web 安全的重要機制。設定錯誤可能導致安全事故,因此需要謹慎處理。

參考資料

在 Docusaurus 部落格把 PageSpeed Insights 幾乎做滿分的方法 — SEO、效能、可及性

· 5 分鐘閱讀

我把這個部落格的行動版 PageSpeed Insights 分數改善到 Performance 99、Accessibility 100、Best Practices 100、SEO 100。從 SEO、效能、可及性 三個面向整理我做過的改動。

改善前的問題

在原生的 Docusaurus 部落格跑 PageSpeed Insights 時,發現了幾個問題。

  • SEO:沒有 meta description、沒有 OGP/Twitter Cards、沒有結構化資料、網站地圖的 priority 未設定
  • 效能:因為 Google Tag Manager (GTM) 同步載入導致未使用的 JS 過大、從外部 CDN 取得 avatar 成為瓶頸
  • 可及性:主要顏色的對比度未達 WCAG AA 標準

SEO 的改善

新增 meta description、OGP、Twitter Cards

docusaurus.config.tsthemeConfig.metadata 中加入站點共通的 meta 資訊。

themeConfig: {
metadata: [
{ name: 'description', content: "ひかりの技術メモ..." },
{ property: 'og:locale', content: 'ja_JP' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:site', content: '@ptrqr' },
],
}

另外,透過 Swizzle 覆寫 src/theme/Layout/index.tsx,為沒有自訂 description 的頁面(例如部落格列表、標籤頁)設定以語系為單位的備用 description。

新增 robots.txt

加入 static/robots.txt,明確告訴爬蟲網站地圖的位置。

BlogPosting JSON-LD(結構化資料)

透過 Swizzle 覆寫 src/theme/BlogPostItem/index.tsx,在文章頁輸出包含 headlinedatePublisheddateModifiedauthorBlogPosting JSON-LD。

不過後來發現 Docusaurus 內建的 BlogPostPage/StructuredData 已經會輸出相同的資料。把自訂的 JSON-LD 移除,改為在內建元件上加上 keywords 的備援(frontMatter.keywordstags)。結構化資料重複會對 SEO 造成負面影響,整理這點相當重要。

WebSite JSON-LD

docusaurus.config.tsheadTags 加入 WebSite 類型的 JSON-LD,讓 Google 能正確辨識站名。

headTags: [
{
tagName: 'script',
attributes: { type: 'application/ld+json' },
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'ひかりの備忘録',
url: 'https://www.hikari-dev.com/',
}),
},
],

自動為所有文章生成 OGP 圖片

建立 scripts/generate-ogp.js,自動根據標籤產生漸層背景的 OGP 圖片。如此一來,所有文章在社群分享時都會帶有吸睛的圖片。為每篇文章的 frontMatter 設定 image: 欄位,若文章已有專屬圖片則優先使用。

改善網站地圖

使用 createSitemapItems 回呼把首頁 priority 設為 1.0、部落格文章設為 0.8。另外從 URL 含有的日期自動抽出 lastmod

hreflang x-default

src/theme/Root.tsx 注入帶有 hreflang="x-default"<link>,將英文頁面(/en/...)對應到預設(日文) URL,讓搜尋引擎能正確辨識語言變體。

const defaultPath = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
const xDefaultUrl = `${siteConfig.url}${defaultPath}`;

<Head>
<link rel="alternate" hreflang="x-default" href={xDefaultUrl} />
</Head>

效能的改善

GTM 延遲載入

移除 @docusaurus/plugin-google-gtag,改在 src/clientModules/gtag.js 裡於 window.load 事件之後動態注入 GTM 腳本。如此一來,可大幅減少阻塞初始渲染的未使用 JS。

function loadGtag() {
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
document.head.appendChild(script);
}

window.addEventListener('load', loadGtag, { once: true });

在 SPA 的頁面切換時使用 Docusaurus 的 onRouteDidUpdate hook 手動呼叫 window.gtag。另外也使用 requestIdleCallback 在閒置時載入加以優化。

將 avatar 自行託管並轉為 WebP

把 avatar 圖片從 GitHub CDN(avatars.githubusercontent.com)移到自行託管。GitHub CDN 的快取 TTL 很短(5 分鐘),在 PageSpeed Insights 上每次都會被視為外部請求。

把 avatar 轉為 WebP,檔案從 34 KB(PNG)降到 3.5 KB,約減少 90%。

圖片尺寸優化與修正 CLS

  • 在 GitHub avatar URL 加上 ?size=64,把 460 px 縮到 64 px(減少 33 KB)
  • 在導航列 logo 加上 width/height 屬性以修正 CLS(Cumulative Layout Shift)
  • <img> 標籤加入 loading="lazy"

導入 rspack / SWC

安裝 @docusaurus/faster,把 webpack 換成 rspack + SWC + lightningCSS。

future: {
v4: true,
experimental_faster: true,
},

如此一來,建置速度和打包體積都改善了。

停用未使用的插件

停用未使用的 docs 外掛,避免不必要的 JS 被傳到用戶端。

僅在行動裝置載入 Google Fonts

Noto Sans JP 在我的站只在行動版需要。利用 matchMedia 判斷行動裝置,僅在行動裝置動態注入字型樣式表,桌面版因此減少約 130 KB 的未使用 CSS。

可及性的改善

修正對比度

把主要色從 #F15EB4 改為 #C82273,在白底下達到對比度 5.3:1(符合 WCAG AA)。深色模式則使用 #F36AB2(在暗底下為 7.0:1)。

文章日期文字的顏色改用 CSS 變數 --post-date-color 管理,淺色模式為 #595959(7.0:1),深色模式為 #9e9e9e

統一字型

把標題與 <strong> 的字型從 Noto Serif JP 改為 Noto Sans JP,以與內文保持一致性。

結果

類別分數
Performance99
Accessibility100
Best Practices100
SEO100

在行動裝置上幾乎達到滿分。

總結

對我影響最大的三項變更如下:

  1. GTM 的延遲載入:大幅減少未使用的 JS,效能分數大幅提升
  2. OGP 與結構化資料的整理:達成 SEO 100,社群分享時的呈現也改善
  3. 修正對比度:符合 WCAG AA 讓可及性達到 100

Docusaurus 預設就能生成高品質網站,但要在 PageSpeed Insights 上追求幾乎滿分,仍需針對 GTM 的載入策略、meta 資訊與可及性的細節做調整。希望對也在做同類改善的人有所幫助。

在 AWS 無伺服器環境下自建部落格留言 API

· 3 分鐘閱讀

想要在這個部落格加入留言功能,因此在 AWS 無伺服器架構下自建了一個 API。介紹其設計與實作。

架構

請求以以下流程處理。

瀏覽器 (www.hikari-dev.com)
↓ HTTPS
API Gateway
├── GET /comment?postId=... → 取得留言
├── POST /comment → 發表留言
└── PATCH /comment/{id} → 管理(切換隱藏)

Lambda (Node.js 20 / arm64)

DynamoDB(儲存留言)
+ SES v2(通知管理者)

程式碼以 TypeScript 撰寫,並以 SAM(Serverless Application Model)管理基礎設施即程式碼(IaC)。Lambda 採用 arm64(Graviton2)以稍微節省成本。

DynamoDB 的資料表設計

資料表名稱為 blog-comments,分區鍵為 postId,排序鍵為 commentId

型別說明
postIdString文章的識別值(例: /blog/2026/03/20/hime
commentIdStringULID(可時序排序的 ID)

因為在排序鍵使用 ULID,使用 QueryCommand 取得的留言會自動按發表順序排列。這也是為什麼沒有使用 UUID 的原因。

垃圾留言過濾

在將留言寫入 DynamoDB 之前,會與 keywords.json 中定義的關鍵字比對。

若命中關鍵字,會以 isHidden: true 自動隱藏,並附加 isFlagged: "1"。若未命中則立即公開。

isFlagged 作為稀疏 GSI(Sparse GSI)的鍵使用。未命中的留言不會帶入此屬性,因此不會在 GSI 中增加多餘的分區,對成本與效能都有利。只要在 DynamoDB Document Client 設定 removeUndefinedValues: true 就能達成。

通知管理者的郵件

每次有留言發表時,會用 SES v2 發信通知自己。郵件內容包含發言者名稱、內容、評分、IP 位址以及是否被標記(flag)等資訊。

郵件發送採非同步進行,即使發送失敗也會忽略錯誤(不影響使用者流程)。這是為了避免影響留言發表的回應速度。

隱私考量

雖然會在 DynamoDB 中儲存 IP 位址與 User-Agent,但不會把它們包含在 GET 端點的回應中。我們在型別定義層就進行分離。

安全性

層級對策
網路使用 AWS WAF 設定每個 IP 每 5 分鐘 100 次的速率限制
CORS僅允許 https://www.hikari-dev.com
管理 APIAPI Gateway 的 API Key 認證(X-Api-Key 標頭)
垃圾留言關鍵字過濾自動隱藏

管理端點(PATCH /comment/{id})只要在 SAM 模板設定 ApiKeyRequired: true 即可啟用 API 金鑰認證。無需自行實作 Lambda Authorizer,較為簡單。

總結

採用無伺服器架構後不需管理伺服器,DynamoDB 的按需付費讓流量低的個人部落格也能把成本降到最低。

程式碼以 SAM + TypeScript + esbuild 打包,僅需執行 sam build && sam deploy 即可部署。

安裝 Firefox 建置版本

· 1 分鐘閱讀

Ubuntu 22.04 似乎預設安裝了 snap 版本的 Firefox,在某些環境下無法啟動,因此記錄如何安裝預先建置的 Firefox。

移除 apt / snap 版本的 Firefox

sudo apt purge firefox
sudo snap remove firefox

安裝 Firefox 建置版本

# 下載
wget "https://download.mozilla.org/?product=firefox-latest-ssl&os=linux64&lang=ja" --trust-server-names

# 解壓縮
tar xvf firefox-*.tar.bz2

# 安裝
sudo cp -r firefox /usr/lib

# 建立執行檔的符號連結
sudo ln -s /usr/lib/firefox/firefox /usr/bin/firefox

# 下載並放置桌面捷徑檔案
sudo mkdir -p /usr/share/applications
sudo wget https://bit.ly/3Mwigwx -O /usr/share/applications/firefox.desktop

如何製作圖示(.ico)

· 1 分鐘閱讀
  1. 建立圖示。

    如何建立圖示

  2. 準備七張尺寸分別為 1624324864128256 的 PNG 圖片。 如何建立圖示

  3. 使用 convert 指令建立圖示。 如何建立圖示

convert *.png favicon.ico