[PHP] Syntax Highlighting ähnlich wie im Forum + Schutz for XSS



  • Hallo,

    ich arbeite an einer kleinen Website, und ich möchte via PHP Syntaxhighlighting einbauen. Dazu kann ein Benutzer im textarea zwischen zwei code(=lang) /code seinen Code schreiben. Wenn nun der gespeicherte Text angezeigt wird, ist es schön farbig. Das stelle ich mir so vor:

    code
    #include <iostream>
    
    int main()
    {
        std::cout << "Hello, World!\n";
    }
    /code (jeweils mit [])
    

    raus kommt folgendes:

    <pre class="code">
    <code class="pp">#include</code> <code class="str">&lt;iostream&gt;</code>
    <code class="kw">int</code> main {
    usw.
    </pre>
    

    Ich mache das momentan mit regex, und suche mir erstmal die code tags:

    $code_pattern = '/[code(=[^\]]*)](.*?)[\/code]/s';
    
    if (preg_match_all($code_pattern, $raw, $matches, PREG_SET_ORDER)) {
        $code = '<pre class="code">';
        foreach ($matches as $match) {
            $code .= highlight_code($match[2], substr($match[1], 1));
        }
        $code .= '</pre>';
    }
    

    Problem: Ich arbeite für user input der in HTML tags gesetzt wird (also nicht für script etc.) mit dieser Funktion (von OWASP abgeschaut)

    function xssafe($data,$encoding='UTF-8') {
       return htmlspecialchars($data, ENT_QUOTES | ENT_HTML5, $encoding);
    }
    

    Mein großes Dilemma hierbei ist, dass ich nicht davor xssafe anwenden kann, da es mir dann alle "<>' etc. verhaut und ich meinen code nicht highlighten kann, und wenn ich es danach anwende würde es mir alle <code> tags verhauen. Wie kann ich jetzt richtig sowohl Server-Side syntax highlighting ausgeben, als auch den user input richtig escapen? Ich hab auch HTMLPurifier, aber ich will eigentlich überhaupt keine Tags zulassen. Wie macht ihr das denn hier im Forum?

    Weitere Probleme wären z.B. bei "unsigned". Wenn ich zuerst die strings ersetze dann ist nacher trotzdem das unsigned blau. Wenn ich die strings nicht als Erstes ersetze, werden nacher die Attribute der vorherigen code tags als strings im code angesehen.

    Vielen Dank im Voraus

    LG
    HarteWare



  • Du rufst xssafe genau dort auf wo du die einzelnen, rohen String-Stücke "anhängst".

    Bzw. falls du mit der Beschreibung nix anfangen kannst zeig mal deine highlight_code Funktion her.
    An dem Code-Teil den du bis jetzt gezeigt hast besteht nämlich erstmal kein Änderungsbedarf.

    Oder, Variante 2:
    * Such dir Zeichen-Codes die du nicht brauchst. Zeichen wie "STX" und "ETX" bieten sich an.
    * Entferne alle diese Zeichen aus dem Input-String
    * Verbastle den neuen Input-String wie gehabt, nur dass du statt "<" bei deinen "<pre>" Tags etc. jetzt "STX" einfügst und statt ">" halt "ETX". Für "&" brauchst du auch noch was - gibt aber noch genug unnötige Character-Codes (siehe z.B. http://www.asciitable.com/)
    * Ruf htmlspecialchars auf
    * Ersetze alle "STX" durch "<", "ETX" durch ">" usw.
    Easy.



  • hustbaer schrieb:

    Du rufst xssafe genau dort auf wo du die einzelnen, rohen String-Stücke "anhängst".

    Bzw. falls du mit der Beschreibung nix anfangen kannst zeig mal deine highlight_code Funktion her.
    An dem Code-Teil den du bis jetzt gezeigt hast besteht nämlich erstmal kein Änderungsbedarf.

    Oder, Variante 2:
    * Such dir Zeichen-Codes die du nicht brauchst. Zeichen wie "STX" und "ETX" bieten sich an.
    * Entferne alle diese Zeichen aus dem Input-String
    * Verbastle den neuen Input-String wie gehabt, nur dass du statt "<" bei deinen "<pre>" Tags etc. jetzt "STX" einfügst und statt ">" halt "ETX". Für "&" brauchst du auch noch was - gibt aber noch genug unnötige Character-Codes (siehe z.B. http://www.asciitable.com/)
    * Ruf htmlspecialchars auf
    * Ersetze alle "STX" durch "<", "ETX" durch ">" usw.
    Easy.

    Hallo,

    vielen Dank für Deine Antwort.

    Das Problem mit xssafe wäre dadurch scheinbar recht einfach gelöst (ich wählte Variante 1). highlight_code sieht so aus im Moment (ganz primitiv):

    function xhighlight_code($text, $lang) {
        static $keywords = array(
            'cpp' => array('int', 'char', 'float', 'double', 'long', 'unsigned', 'signed'),
        );
        if (!isset($keywords[$lang])) return xssafe($text);
    
        $text = preg_replace( '/"[^"]*"/', '<code class="str">' .xssafe('$0') . '</code>', $text);  // kann noch keine "\"" strings
    
        foreach ($keywords[$lang] as $word) {
            $text = preg_replace("/\b$word\b/", '<code class="kw">' . xssafe('$0') . '</code>', $text);
        }
    
        return $text;
    }
    

    Oder ist es wirklich? Ich meine ich habe etwas übersehen: Es werden jetzt nur keywords und strings escaped, alles dazwischen nicht. Evtl. also doch Variante 2.

    Ein weiteres Problem wäre sowas hier:

    char s[] = "Hello, unsigned World!\n";
    

    Es sieht für mich so aus, als würden die patterns nur komplizierter werden. Ich überlege ob es nicht sinnvoller wäre, in einer selbst gebastelten Funktion/FSM den Text in einem einzigen Durchlauf zu verarbeiten. Da kann ich mir ganz einfach merken, ob ich gerade zwischen zwei "" bin und deshalb das keyword nicht hervorgehoben werden soll.



  • Wie soll xssafe('$0') funktionieren? Da wird immer '$0' rauskommen. Dass du es "in" einem Aufruf von preg_replace verwendest ändert daran nichts. xssafe('$0') wird ja ausgewertet bevor preg_replace anfängt zu laufen, also auch bevor preg_replace das $0 durch den Match ersetzen wird.
    Nönö.

    Mit preg_replace wird nur Variante 2 funktionieren. Du könntest aber auch einfach preg_replace_callback verwenden.
    Allerdings löst das natürlich immer noch nicht das Problem mit Keywords in Strings. Vielleicht solltest du erstmal das lösen, und dir erst danach Gedanken über die HTML-Encoding Sache machen 😉

    HarteWare schrieb:

    Es sieht für mich so aus, als würden die patterns nur komplizierter werden. Ich überlege ob es nicht sinnvoller wäre, in einer selbst gebastelten Funktion/FSM den Text in einem einzigen Durchlauf zu verarbeiten.

    Ja, geht auch. Ist aber auch nicht ohne.



  • hustbaer schrieb:

    Wie soll xssafe('$0') funktionieren? Da wird immer '$0' rauskommen. Dass du es "in" einem Aufruf von preg_replace verwendest ändert daran nichts. xssafe('$0') wird ja ausgewertet bevor preg_replace anfängt zu laufen, also auch bevor preg_replace das $0 durch den Match ersetzen wird.
    Nönö.

    Mit preg_replace wird nur Variante 2 funktionieren. Du könntest aber auch einfach preg_replace_callback verwenden.
    Allerdings löst das natürlich immer noch nicht das Problem mit Keywords in Strings. Vielleicht solltest du erstmal das lösen, und dir erst danach Gedanken über die HTML-Encoding Sache machen 😉

    HarteWare schrieb:

    Es sieht für mich so aus, als würden die patterns nur komplizierter werden. Ich überlege ob es nicht sinnvoller wäre, in einer selbst gebastelten Funktion/FSM den Text in einem einzigen Durchlauf zu verarbeiten.

    Ja, geht auch. Ist aber auch nicht ohne.

    Aaaah das mit dem xssafe('$0') war voll daneben 🙄

    Ich komm einfach nicht drauf, wie ich das lösen soll, außer ich füge noch dem regex für die keywords irgendwas hinzu, z.B. darf vor dem keyword kein offenes <code> tag stehen, außer danach kommt wieder ein </code> entsprechend. Ich habe ansätze für einfache Lexer gefunden, die Zeilenweise mit regex arbeiten. Ich glaube die können aber z.B. keine multiline comments.

    Mein anderer Ansatz sieht so aus, funktioniert für mein primitives Beispiel, aber ist wohl nicht sehr schön finde ich.

    function highlight_str($code, &$i, $end, $quot, $class) {
    	$esc = false;  // ignore closing quote when esc === tue
    	$str = $quot;
    
    	do {
    		$str .= $code[++$i];
    		$esc = $code[$i - 1] === '\\' ? !$esc : false;
    	} while ($i+1 < $end && ($code[$i] !== $quot || $esc));
    
    	return '<code class="'. $class . '">' . xssafe($str) . '</code>';
    }
    
    function highlight_keyword($word, $lang) {
    	static $keywords = array(
    		'default' => array('return', 'for', 'while', 'do', 'if', 'else', 'switch'),
    		'cpp' => array('int', 'char', 'float', 'double', 'long', 'unsigned', 'signed'),
    		'php' => array('array', 'strlen', 'function', 'elseif', 'in_array', 'isset'),
    	);
    
    	if (isset($keywords[$lang]) && in_array($word, $keywords[$lang]) || in_array($word, $keywords['default']))
    		return '<code class="kw">' . xssafe($word) . '</code>';
    	return xssafe($word);
    }
    
    function highlight($code, $lang) {
    	$end = strlen($code);
    	$res = '';
    	$tok = '';
    
    	for ($i = 0; $i < $end; ++$i) {
    		$ch = $code[$i];
    
    		if (ctype_alpha($ch) || $ch == '_')
    			$tok .= $ch;
    		else {
    			$res .= highlight_keyword($tok, $lang);
    			$tok = '';
    		}
    
    		switch ($ch) {
    		case "'":
    			$res .= highlight_str($code, $i, $end, "'", "sstr");
    			break;
    		case '"':
    			$res .= highlight_str($code, $i, $end, '"', "dstr");
    			break;
    		default:
    			// if ch wasn't consumed
    			if ($tok === '') $res .= $ch;
    		}
    	}
    	return $res . highlight_keyword($tok, $lang);
    }
    


  • Also...
    Ein Ansatz wäre...

    Erstmal gehst du über den ganzen Code drüber und behandelst nur Kommentare, String-Literals und Character-Literals. Dazu suchst du einfach den ersten Anfang von einem der genannten - was auch immer als erstes kommt gilt. Das geht z.B. mit preg_match . Dann guckst du was du gefunden hast. Abhängig davon suchst du wo es endet - geht wieder mit preg_match.
    Wenn es ein /* Kommentar ist suchst du das nächste */ ab dieser Position.
    Wenn es ein // Kommentar ist suchst du das nächste Zeilenende ab dieser Position.
    Wenn es ein String-Literals ist suchst du das nächste " vor dem gar kein oder eine gerade Anzahl von \ steht ODER das nächste Zeilenende.
    Wenn es ein Charachter-Literal ist suchst du das nächste ' vor dem gar kein oder eine gerade Anzahl von \ steht ODER das nächste Zeilenende.

    Jetzt weisst du wo das Ding anfängt und wo es aufhört. Damit hast du einen "davor", "darin" und "dahinter" Teil. (Genaugenommen sind es 5 Teile, denn du hast noch die beiden Delimiter zwischen den drei Teilen, aber die kannst du vermutlich einfach mit in den "darin" Teil packen.)

    Sagen wir die Funktion die den Code bearbeitet heisst einfach process . Und sagen wir du hast nen String-Literal gefunden. Dann ist der Returnwert von process() für diesen Fall einfach:

    process_2($davor) . process_string_literal($darin) . process($dahinter)

    Die anderen Fälle analog, nur halt mit jeweils anderen Funktionen statt process_string_literal .

    process_2 ist eine weitere Funktion die du schreiben musst, die den ganzen Rest macht, also alles abgesehen von Kommentaren, String-Literals und Character-Literals.

    process_string_literal wird vermutlich nicht viel mehr machen als HTML Encoding (also htmlspecialchars anwenden) UND dann die "<code class='...'>" und "</code>" Dinger vorne und hinten anzufügen. Also z.B. einfach
    return "<code class='...'>" . htmlspecialchars($input) . "</code>"
    Es könnte aber mehr machen, z.B. wenn du ein \n in "Blah, blub.\n" anders einfärben willst als den "normalen" String-Inhalt.

    Und process($dahinter) ist hier einfach ein rekursiver Aufruf der process Funktion die du gerade schreibst. Denn $dahinter kann ja wieder alles mögliche, inklusive weiterer Kommentare, String-Literals und Character-Literals enthalten.

    Damit hast du schonmal nen relativ "unangenehmen" Teil erschlagen.

    process_2 könnte dann weiter machen indem es alles in Zeilen zerlegt und für jede Zeile process_line aufruft und die einzelnen Ergebnisse der Zeilen wieder zusammenhängt.

    process_line guckt dann als erstes mal ob die Zeile eine Präprozessorzeile ist und ruft dann z.B. abhängig davon einfach process_pp_line oder process_code_line auf.

    Und dann bleibt, wenn du es einfach halten willst, nicht mehr viel übrig als ein preg_replace nach dem anderen draufzuklopfen.

    Das ganze hat dann ein paar recht angenehme Eigenschaften:

    1. Jede process_* Funktion bekommt als Input immer nur Text ohne jegliches HTML-Zeugs drinnen.
    2. Jede process_* Funktion liefert als Ergebnis immer einen Teilstring zurück der bereits *vollständig* bearbeitet ist.
    3. Jede process_* Funktion macht selbst nur einen kleinen, relativ überschaubaren Teil, und ruft einfach weitere process_* Funktionen auf um den Rest zu erledigen.

    ----

    Gut, einen Haken hat das Ganze noch. Und zwar brichst du im ersten Durchgang Präprozessoranweisungen die Strings/Kommentare o.ä. enthalten in mehrere Teile auf. Das führt dazu dass Bei der Zeile

    #pragma comment /*comment? WTF?*/(lib, "blah")

    in mehrere Teile auf. Wobei alle bis auf den 1. dann fälschlicherweise von process_code_line bearbeitet werden.

    Lässt sich auch fixen, aber macht das ganze etwas komplizierter.



  • Das ist aber eine sehr ausführliche Erläuterung, vielen Dank!

    Ich bin schon dran, aber irgendwie fällt mir das nicht ganz so leicht, hauptsächlich das Formulieren der patterns.

    Zufrieden bin ich mit meiner Lösung (noch) nicht, weil viel gehardcoded ist (z.B. wäre es relativ aufwendig jetzt für Python Kommentare mit # bzw. '''...''' hinzuzufügen. Außerdem ist noch etwas viel duplizierter Code nach meinem Geschmack. Vermutl. fände man noch weitere Mängel.

    Ich denke irgendwo muss man halt auch Kompromisse machen. Die "richtigen libs" haben, soweit ich das z.B. bei Pigments erkennen kann, für jede Sprache einen eigenen Lexer implementiert und haben dann eben den Text in Form von Tokens (comment, keyword, ...).

    Momentan siehts bei mir so aus (ziemlich unleserlich finde ich, am liebsten würde ich jeder capture group extra noch eine Variable geben mit aussagekräftigem Namen):

    function process($text) {
    	// 4 (capture) groups
    	// matches (...)[ code(=...)](...)[ /code](...)
    	static $pattern = '/(.*?)[code(=[^\]]+)?](.*?)[\/code](.*)/s';
    
    	if (empty($text)) return '';
    
    	if (preg_match($pattern, $text, $m))
    		return process_markdown($m[1])
    	         . '<pre class="code">' . process_code(trim($m[3]), substr($m[2],1)) . '</pre>'
    	         . process($m[4]);
    
    	return process_markdown($text);
    }
    
    function process_markdown($text) {
    	return xssafe($text);
    }
    
    function process_code($code, $lang) {
    	// Finds the next start of a comment, literal, ...
    	// matches (...)("|'|//|/*)(...)
    	static $pattern = '~(.*?)("|\'|//|/\*)(.*)~s';
    
    	if (preg_match($pattern, $code, $m)) {
    		switch($m[2]) {
    		case '/*':
    			if (preg_match('/(.*?\*\/)(.*)/s', $m[3], $n))
    				return process_instr($m[1], $lang) 
    				     . process_comment($m[2] . $n[1])
    				     . process_code($n[2], $lang);
    			return process_instr($m[1], $lang) . process_comment($m[2] . $m[3]);
    		case '//':
    			preg_match('/(.*?)(\n|$)(.*)/s', $m[3], $n);
    			return process_instr($m[1], $lang)
    				 . process_comment($m[2] . $n[1])
    				 . process_code($n[3], $lang);
    		case '"':
    		case "'":
    			preg_match("/(([^\n{$m[2]}\\\]|\\\\.)*({$m[2]}|\n|$))(.*)/s", $m[3], $n);
    			return process_instr($m[1], $lang)
    				 . process_str_literal($m[2] . $n[1])
    				 . process_code($n[4], $lang);
    		}
    	}
    	return process_instr($code, $lang);
    }
    
    function process_comment($text) {
    	return '<code class="mlcom">' . xssafe($text) . '</code>';
    }
    
    function process_instr($code, $lang) {
        // evtl. noch keywords oder sonstiges highlighten
    	return xssafe($code);
    }
    
    function process_str_literal($lit) {
    	if ($lit[0] == '"')
    		return '<code class="dstr">' . xssafe($lit) . '</code>';
    	return '<code class="sstr">' . xssafe($lit) . '</code>';
    }
    

Anmelden zum Antworten