-
Notifications
You must be signed in to change notification settings - Fork 75
特定のURL / ドメインのリンクカードが文字化けするバグを修正 #9778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0f2983e
0fc3793
e9db542
71aa252
1c1a4c9
18d88d8
67b7750
2058f24
afc2b33
98e037d
7082fa7
45c4e67
09fbaa1
9af4dbd
4cdc678
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require 'net/http' | ||
|
|
||
| module LinkFetcher | ||
| module Fetcher | ||
| class TooManyRedirects < StandardError; end | ||
|
|
||
| FETCH_ERRORS = [ | ||
| SocketError, | ||
| Errno::ECONNREFUSED, | ||
| Errno::EHOSTUNREACH, | ||
| Errno::ETIMEDOUT, | ||
| Net::OpenTimeout, | ||
| Net::ReadTimeout, | ||
| EOFError, | ||
| OpenSSL::SSL::SSLError | ||
| ].freeze | ||
|
|
||
| DEFAULT_TIMEOUT = 5 | ||
| DEFAULT_REDIRECT_LIMIT = 5 | ||
|
|
||
| module_function | ||
|
|
||
| def fetch(url, redirect_limit = DEFAULT_REDIRECT_LIMIT) | ||
| raise TooManyRedirects if redirect_limit.negative? | ||
| return nil unless LinkChecker::Checker.valid_url?(url) | ||
|
|
||
| uri = Addressable::URI.parse(url).normalize | ||
| safe_ips = SafeIpResolver.resolve_ips(uri) | ||
| return nil if safe_ips.nil? | ||
|
|
||
| http = build_http(uri, safe_ips) | ||
| response = http.request_get(uri.request_uri) | ||
|
|
||
| if response.is_a?(Net::HTTPRedirection) | ||
| redirect_url = build_redirect_url(url, response) | ||
| return nil unless redirect_url | ||
|
|
||
| fetch(redirect_url, redirect_limit - 1) | ||
| else | ||
| response | ||
| end | ||
| rescue TooManyRedirects | ||
| Rails.logger.info("[LinkFetcher] Too many redirects: #{url}") | ||
| nil | ||
| rescue *FETCH_ERRORS => e | ||
| Rails.logger.warn("[LinkFetcher] #{e.class}: #{e.message}") | ||
| nil | ||
| end | ||
|
|
||
| def build_http(uri, ips) | ||
| http = Net::HTTP.new(uri.host, uri.inferred_port) | ||
| if uri.scheme == 'https' | ||
| http.use_ssl = true | ||
| http.verify_mode = OpenSSL::SSL::VERIFY_PEER | ||
| end | ||
| http.open_timeout = DEFAULT_TIMEOUT | ||
| http.read_timeout = DEFAULT_TIMEOUT | ||
| http.response_body_encoding = true | ||
| http.ipaddr = ips.first | ||
| http | ||
| end | ||
|
|
||
| def build_redirect_url(url, response) | ||
| location = response['location'] | ||
| return nil if location.blank? | ||
|
|
||
| URI.join(url, location).to_s | ||
| rescue URI::InvalidURIError | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module LinkFetcher | ||
| module SafeIpResolver | ||
| BLOCKED_IP_RANGES = [ | ||
| # ループバック(SSRFで内部サービスに到達可能なためブロック) | ||
| IPAddr.new('127.0.0.0/8'), | ||
| IPAddr.new('::1/128'), | ||
|
|
||
| # プライベートネットワーク(SSRFで社内/VPCリソースに到達可能なためブロック) | ||
| IPAddr.new('10.0.0.0/8'), | ||
| IPAddr.new('172.16.0.0/12'), | ||
| IPAddr.new('192.168.0.0/16'), | ||
|
|
||
| # リンクローカル(メタデータAPI等に到達可能なためブロック) | ||
| IPAddr.new('169.254.0.0/16'), | ||
| IPAddr.new('fe80::/10'), | ||
|
|
||
| # IPv6 ユニークローカルアドレス(内部ネットワークへのアクセスを防ぐためブロック) | ||
| IPAddr.new('fc00::/7'), | ||
|
|
||
| # 旧site-local(廃止済みだが内部ネットワーク扱いのため念の為ブロック) | ||
| IPAddr.new('fec0::/10'), | ||
|
|
||
| # 無効・未指定アドレス(接続先として無効、且つ挙動が不安定のためブロック) | ||
| IPAddr.new('0.0.0.0/8'), | ||
| IPAddr.new('::/128'), | ||
|
|
||
| # CGNAT(外部から到達すべきでない領域のためブロック) | ||
| IPAddr.new('100.64.0.0/10'), | ||
|
|
||
| # IPv4-mapped IPv6(IPv4アドレスをIPv6で表現してのフィルタ回避を防ぐためブロック) | ||
| IPAddr.new('::ffff:0:0/96') | ||
| ].freeze | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| class NonHttpUriError < StandardError; end | ||
| class AddressNotFoundError < StandardError; end | ||
| class UnsafeIpError < StandardError; end | ||
|
|
||
| module_function | ||
|
|
||
| def resolve_ips(uri) | ||
| raise NonHttpUriError unless valid_http_uri?(uri) | ||
|
|
||
| ips = Resolv.getaddresses(uri.host) | ||
| raise AddressNotFoundError if ips.empty? | ||
| raise UnsafeIpError unless all_ips_safe?(ips) | ||
|
|
||
| ips | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| rescue NonHttpUriError => e | ||
| Rails.logger.info("[SafeResolver] #{e.class}: scheme=#{uri.scheme} host=#{uri.host}") | ||
| nil | ||
| rescue AddressNotFoundError => e | ||
| Rails.logger.info("[SafeResolver] #{e.class}: host=#{uri.host}") | ||
| nil | ||
| rescue UnsafeIpError => e | ||
| Rails.logger.warn("[SafeResolver] #{e.class}: unsafe ip detected host=#{uri.host}") | ||
| nil | ||
| end | ||
|
|
||
| def valid_http_uri?(uri) | ||
| port = uri.port || uri.inferred_port | ||
|
|
||
| port.in?([80, 443]) && uri.scheme.downcase.in?(%w[http https]) | ||
| end | ||
|
Comment on lines
+61
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "safe_ip_resolver.rb" -type fRepository: fjordllc/bootcamp Length of output: 106 🏁 Script executed: cd app && find . -name "safe_ip_resolver.rb" -type f | head -20Repository: fjordllc/bootcamp Length of output: 102 🏁 Script executed: fd -t f "safe_ip_resolver" 2>/dev/null || find . -name "*safe_ip_resolver*" -type f 2>/dev/null | head -20Repository: fjordllc/bootcamp Length of output: 154 🏁 Script executed: rg -l "safe_ip_resolver" --type rbRepository: fjordllc/bootcamp Length of output: 87 🏁 Script executed: rg "valid_http_uri" -A 20 -B 5 --type rbRepository: fjordllc/bootcamp Length of output: 87 🏁 Script executed: cat -n app/models/link_fetcher/safe_ip_resolver.rbRepository: fjordllc/bootcamp Length of output: 2408 🏁 Script executed: cat -n test/models/link_fetcher/safe_ip_resolver_test.rbRepository: fjordllc/bootcamp Length of output: 3184 scheme/host 欠落URIを fail-closed にしてください。
🛡️ 修正例 def valid_http_uri?(uri)
+ return false if uri.blank?
+
+ scheme = uri.scheme&.downcase
+ return false if scheme.blank? || uri.host.blank?
+
port = uri.port || uri.inferred_port
- port.in?([80, 443]) && uri.scheme.downcase.in?(%w[http https])
+ port.in?([80, 443]) && scheme.in?(%w[http https])
end🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #9778 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🧠 Learnings used |
||
|
|
||
| def all_ips_safe?(ips) | ||
| ip_addrs = ips.map { |ip| IPAddr.new(ip) } | ||
| ip_addrs.all? { |ip| safe_ip?(ip) } | ||
| end | ||
|
|
||
| def safe_ip?(ip_addr) | ||
| BLOCKED_IP_RANGES.none? { |ip_range| ip_range.include?(ip_addr) } | ||
| end | ||
| end | ||
| end | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
http.response_body_encoding = trueとすることで、encodingをHTMLから推測してくれるようになります。