Python urljoin の正しい使い方とセキュリティの注意点

  • URLをコピーしました!
目次

はじめに

Python で Web スクレイピングや REST API クライアントを実装する際、「ベース URL(ドメイン)」と「相対パス」を結合して絶対 URL を組み立てる場面が頻繁にあります。

base = "https://example.com/"
path = "api/v1/users"
# これらを結合したい

このとき、文字列の足し算(+ 演算子)で結合してしまうと、スラッシュ(/)の重複や欠落によるバグが発生しやすくなります。さらに、外部入力を URL に組み込むケースでは、結合方法によって意図しない外部ホストへのアクセス(SSRF など)につながるリスクもあります。

Python 標準ライブラリの urllib.parse.urljoin は、RFC 3986 に準拠した URL 結合ロジックを提供しており、これらの問題に対処する選択肢になります。一方で、挙動には独特のクセがあり、把握せずに使うと別のバグを生む原因にもなります。

本記事では、urljoin の基本的な使い方、スラッシュの有無による挙動の違い、そしてセキュリティ上の注意点まで、実務で必要な知識を整理します。

この記事でわかること
  • urljoin を使った正しい URL 結合方法
  • 文字列結合(+)と比較したメリット
  • ベース URL の末尾スラッシュ有無による挙動の違い(4 パターン)
  • urljoin のセキュリティ上の落とし穴と対処法
  • requests ライブラリや他のパス結合関数との使い分け

urljoin の基本的な使い方

urljoin は Python の標準ライブラリ urllib.parse モジュールに含まれています。使い方はシンプルで、「ベースとなる URL」と「結合したい URL(相対パス)」を引数に渡すだけです。

参考: urllib.parse — Python 3 公式ドキュメント
“Construct a full (“absolute”) URL by combining a “base URL” (base) with another URL (url). Informally, this uses components of the base URL, in particular the addressing scheme, the network location and (part of) the path, to provide missing components in the relative URL.”
(ベース URL と別の URL を結合して、完全な絶対 URL を組み立てます。具体的には、ベース URL のスキーム、ネットワークロケーション、パスの一部を使い、相対 URL の不足コンポーネントを補完します)
https://docs.python.org/3/library/urllib.parse.html

基本構文

from urllib.parse import urljoin

urljoin(base, url)
  • 第 1 引数(base): 基底となる URL(例: https://example.com/
  • 第 2 引数(url): 結合したい相対パスなど(例: next/page

実装例

ブログのトップページ URL とカテゴリーのパスを結合する例です。

from urllib.parse import urljoin

# ベースとなる URL
base_url = 'https://friendsnow.hatenablog.com/'

# 結合したい相対パス
relative_url = 'archive/category/プログラミング-Python'

# 結合を実行
full_url = urljoin(base_url, relative_url)

print(full_url)

▼ 実行結果

https://friendsnow.hatenablog.com/archive/category/プログラミング-Python

ベース URL と相対パスが結合され、絶対 URL が生成されました。

なお、Python 3.5 以降の urljoinRFC 3986(STD66) のセマンティクスに準拠した挙動になっています。それ以前のバージョン(3.4 以下)とは結果が異なる場合があるため、レガシー環境を扱う際は注意が必要です。

参考: urllib.parse.urljoin の仕様変更
“Changed in version 3.5: Behavior updated to match the semantics defined in RFC 3986.”
(バージョン 3.5 で変更: 挙動が RFC 3986 で定義されたセマンティクスに合わせて更新されました)
https://docs.python.org/3/library/urllib.parse.html

スラッシュ(/)の有無による挙動の違い

urljoin を使ううえで最も注意が必要なのが、「ベース URL の末尾」と「結合する相対 URL の先頭」のスラッシュ(/)の扱いです。

以下の 4 パターンを把握しておくと、想定外の結合結果を防ぎやすくなります。

パターン A: Base の末尾に「/」がある場合(通常)

ベース URL がディレクトリとして扱われ、最も直感的な結合になります。

base = "http://example.com/api/"  # 末尾あり
path = "users"

print(urljoin(base, path))
# 結果: http://example.com/api/users
# → 末尾に追加される

パターン B: Base の末尾に「/」がない場合(置換される)

末尾にスラッシュがない場合、urljoin はベースの最後の部分(ここでは api)を「ファイル名」として扱います。そのため、最後のセグメントが取り除かれてから結合されます。

base = "http://example.com/api"   # 末尾なし(ファイル扱い)
path = "users"

print(urljoin(base, path))
# 結果: http://example.com/users
# → 「api」が消えて「users」に置き換わる

これは、ブラウザで http://example.com/api というページを表示しているときに <a href="users"> というリンクをクリックした場合と同じ挙動です。同じ階層にある別のリソースへの遷移として解釈されます。

パターン C: 相対 URL が「/」で始まる場合(ルート相対パス)

結合する URL の先頭にスラッシュがある場合、ベース URL のパス部分は無視され、ドメイン直下(ルート)からのパスとして扱われます。

base = "http://example.com/api/v1/"
path = "/users"  # 先頭あり(ルート指定)

print(urljoin(base, path))
# 結果: http://example.com/users
# → 「/api/v1/」は無視され、ドメイン直下に配置される

パターン D: 相対 URL が「//」で始まる場合(スキーマ相対 URL)

先頭が // で始まる URL は「スキーマ相対 URL」と呼ばれ、ベース URL のスキーム(http / https)だけを引き継ぎ、ホスト名(netloc)が置き換わります

base = "https://example.com/api/v1/"
path = "//other.com/users"

print(urljoin(base, path))
# 結果: https://other.com/users
# → ホストが「other.com」に置き換わる

このパターンは、外部入力を相対 URL として扱うコードで意図しないホストへのリクエストを発生させる原因になりやすく、後述するセキュリティ上の注意点に直結します。

挙動まとめ(早見表)

パターンBase URL(第 1 引数)Relative URL(第 2 引数)結合結果解説
A(通常)…/folder/file…/folder/fileディレクトリ配下に追加
B(置換)…/folderfile…/filefolderfile に置換される
C(ルート)…/folder//file…/fileドメイン直下に配置
D(スキーマ相対)https://a.com/x///b.com/yhttps://b.com/yホストが置き換わる

開発時はベース URL の末尾スラッシュの有無を意識することをおすすめします。ディレクトリ配下にパスを追加したい場合は、ベース URL の末尾に必ず / を付けておくと、想定外の置換を避けられます。

文字列結合(+)との比較

ここまで読んで、「仕様が複雑なので自前で + 演算子で結合したほうが簡単では」と感じる方もいるでしょう。しかし、文字列結合で安全な URL を組み立てようとすると、コードは想定以上に複雑になります。

文字列結合(+)で書いた場合

スラッシュの重複や欠落を自前で制御する必要があります。

base = "http://example.com/api"
path = "users"

# そのまま足すと…
# http://example.com/apiusers (スラッシュ抜け)

# 自力で制御しようとすると…
if not base.endswith("/") and not path.startswith("/"):
    full_url = base + "/" + path
elif base.endswith("/") and path.startswith("/"):
    full_url = base[:-1] + path
else:
    full_url = base + path

このような条件分岐を URL 結合のたびに書くのは、可読性・保守性の観点でも望ましくありません。

urljoin を使った場合

urljoin は RFC 3986 に基づいて、これらの処理を内部で自動的に行います。

from urllib.parse import urljoin

# これだけで OK
full_url = urljoin(base, path)

スラッシュの有無を気にせず、標準ライブラリに処理を委ねられる点が最大のメリットです。ただし、前述の「パターン B(末尾スラッシュなしによる置換)」の挙動だけは把握したうえで使う必要があります。

【重要】urljoin のセキュリティ上の注意点

urljoin には、Python 公式ドキュメントでも明示されているセキュリティ上の落とし穴があります。第 2 引数に絶対 URL が渡されると、ベース URL が完全に無視されるという挙動です。

参考: urllib.parse — Python 3 公式ドキュメント
“Because an absolute URL may be passed as the url parameter, it is generally not secure to use urljoin with an attacker-controlled url. For example in, urljoin(“https://website.com/users/”, username), if username can contain an absolute URL, the result of urljoin will be the absolute URL.”
(第 2 引数には絶対 URL が渡される可能性があるため、攻撃者が制御可能な URL に対して urljoin を使うことは一般的に安全ではありません。たとえば urljoin(“https://website.com/users/”, username) において、username に絶対 URL が含まれていた場合、urljoin の結果はその絶対 URL になります)
https://docs.python.org/3/library/urllib.parse.html

危険な挙動の実例

ユーザー入力を相対パスとして扱うつもりで urljoin に渡したケースを見てみます。

from urllib.parse import urljoin

base = "https://my-service.com/users/"

# 通常の入力(想定どおり)
user_input = "alice"
print(urljoin(base, user_input))
# 結果: https://my-service.com/users/alice

# 攻撃者が絶対 URL を入力した場合
user_input = "https://attacker.example/steal"
print(urljoin(base, user_input))
# 結果: https://attacker.example/steal
# → ベース URL が完全に無視される

第 2 引数に絶対 URL を渡すと、ベース URL の情報がすべて破棄され、結果は第 2 引数そのものになります。この挙動は SSRF(Server-Side Request Forgery) などの脆弱性につながる可能性があります。

対処の考え方

外部入力を urljoin の第 2 引数に渡す処理を実装する場合は、以下のような対処を組み合わせることをおすすめします。

  • 入力値の事前検証: 絶対 URL(http://// で始まる文字列)を弾く、もしくはエスケープする
  • 結合後の URL 検証: urlparse で結合結果の netloc を解析し、想定したホストと一致するかチェックする
  • 許可リスト方式: 許可されたホスト名のリストと照合する

以下は結合後にホスト名を検証する例です。

from urllib.parse import urljoin, urlparse

ALLOWED_HOST = "my-service.com"

def safe_join(base: str, user_input: str) -> str:
    joined = urljoin(base, user_input)
    parsed = urlparse(joined)
    if parsed.netloc != ALLOWED_HOST:
        raise ValueError(f"許可されていないホストです: {parsed.netloc}")
    return joined

# 通常入力 → 正常に結合される
print(safe_join("https://my-service.com/users/", "alice"))

# 悪意ある入力 → ValueError が発生
print(safe_join("https://my-service.com/users/", "https://attacker.example/steal"))

ユーザー入力をそのまま urljoin に渡す実装は避け、ホスト名の検証を組み合わせて利用することをおすすめします。

urljoin のその他の落とし穴

セキュリティ以外にも、urljoin には把握しておきたい挙動がいくつかあります。

クエリ文字列とフラグメントの扱い

第 2 引数にクエリ文字列(?…)やフラグメント(#…)が含まれる場合、ベース URL のクエリ・フラグメントは破棄されます。

from urllib.parse import urljoin

base = "https://example.com/page?lang=ja#top"

# 相対パスのみ
print(urljoin(base, "next"))
# 結果: https://example.com/next
# → ベース側のクエリ「?lang=ja」とフラグメント「#top」は破棄される

# 相対パス + クエリ
print(urljoin(base, "next?page=2"))
# 結果: https://example.com/next?page=2

クエリ文字列を残したまま結合したい場合は、urlencode などで自前で構築するか、furlyarl などの外部ライブラリを検討する余地があります。

os.path.join との混同に注意

URL 結合と似たような場面で、os.path.joinposixpath.join を使ってしまうケースがあります。これらはファイルシステムのパス結合用の関数であり、URL 結合には適していません。

import os
from urllib.parse import urljoin

# os.path.join は OS 依存(Windows ではバックスラッシュになる場合あり)
print(os.path.join("https://example.com/api/", "users"))
# Linux/macOS: https://example.com/api/users
# Windows:     https://example.com/api\users  ← 不正な URL

# urljoin は OS 非依存で常に正しく結合される
print(urljoin("https://example.com/api/", "users"))
# 結果: https://example.com/api/users

URL 結合には urljoin、ファイルパス結合には pathlib または os.path.join と、用途で使い分けることをおすすめします。

用途別の使い分け早見表

URL や文字列を結合する手段は urljoin 以外にもいくつかあります。用途に応じた使い分けを以下にまとめます。

手段主な用途特徴向いているケース
urljoinURL 結合RFC 3986 準拠。スラッシュ処理を自動化ベース URL +相対パスを結合する場面全般
f-string / +文字列結合完全に手動制御結合パターンが固定で、入力が信頼できる場合のみ
os.path.joinファイルパス結合OS 依存(Windows でバックスラッシュ)URL には非推奨、ローカルファイル操作向け
posixpath.joinPOSIX 形式パス結合常にスラッシュ区切りURL のパス部分のみ操作したい場合に限定的に有効
pathlib.PurePosixPathパス操作(オブジェクト指向)パスとしての操作が容易URL のパス分解・再構築
furl / yarl(外部)高機能な URL 操作クエリ・フラグメント編集が容易URL を頻繁に組み立て・編集する用途

実務では、シンプルな URL 結合は urljoin、複雑なクエリ操作を含む場合は yarlfurl という使い分けが扱いやすい構成です。

requests ライブラリとの併用例

Web スクレイピングや REST API クライアントでは、requests ライブラリと urljoin を組み合わせるケースが頻出します。requests 自体には URL 結合機能が組み込まれていないため、相対パスでのリクエスト送信には urljoin を併用するのが一般的です。

import requests
from urllib.parse import urljoin

API_BASE = "https://api.example.com/v1/"

def fetch_user(user_id: int) -> dict:
    url = urljoin(API_BASE, f"users/{user_id}")
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

# 呼び出し例
user = fetch_user(123)
# → https://api.example.com/v1/users/123 にリクエスト送信

複数のエンドポイントを扱う場合は、ベース URL を一元管理しておくことで、ドメイン変更やバージョン切り替え時の修正範囲を最小化できます。

なお、requests.Session を使った実装でも同様にベース URL の管理に urljoin が有効です。Session をベース URL 付きで使いたい場合は、requests-toolbeltBaseUrlSession などのサードパーティ拡張も選択肢になります。

まとめ

本記事では、Python の urllib.parse.urljoin を安全に使うためのポイントを解説しました。

  • URL の結合は + ではなく urljoin を使うことで、RFC 3986 準拠の処理が任せられる
  • ベース URL の末尾にスラッシュがない場合、最後のパスセグメントが置換される
  • スキーマ相対 URL(// で始まる URL)はホスト名が置き換わる
  • 第 2 引数に絶対 URL が渡されるとベース URL が無視されるため、外部入力を扱う際は検証が必要
  • クエリ・フラグメント付き URL の結合では、ベース側の情報が破棄される点に注意
  • os.path.join と混同しない(URL 結合には urljoin、ファイルパスには pathlib
  • requests と組み合わせると、API クライアント実装でベース URL を一元管理しやすい

以上、最後までお読みいただきありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

関西を拠点に活動する、現役インフラエンジニア。経験20年超。

大手通信キャリアにて、中〜大規模インフラ(ネットワーク・サーバ・クラウド・セキュリティ)の設計・構築およびプロジェクトマネジメントに従事。現場で直面した技術課題への対処や、最新の脆弱性情報への実務対応を、一次情報として発信しています。

保有資格
CCIE Lifetime Emeritus(取得から20年以上)/ VCAP-DCA / Azure Solutions Architect Expert

▶ 運営者プロフィール(詳細)

目次